Files
opencode-krates-connector/design/Krates.dc.html
Hermes Agent f55f31a6d9 feat: implement Spotlight Krate Creation workflow
- Add type-to-trigger Spotlight with keyboard (any character)
- Add canvas click to open Spotlight
- Implement keyboard navigation (↑↓ Enter Esc)
- Add keyboard shortcut handlers and spotlight store
- Create useSpotlight hook with fuzzy search
- Create mock Kubernetes resources for initial testing
- Implement krate creation with collision detection
- Add Quick Actions (all pods, services, deployments, namespaces)
- Create Spotlight with filter chips and result rendering
- Add Spotlight state management with setQuery, setFilter, setSel
- Include design specs (Krates.dc.html, server.js, support.js)
2026-06-16 12:27:47 -04:00

882 lines
102 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#0b0e13}
::selection{background:rgba(120,200,230,.28)}
@keyframes spotIn{from{opacity:0;transform:translate(-50%,-8px)}to{opacity:1;transform:translate(-50%,0)}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes popIn{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:scale(1)}}
@keyframes blink{50%{opacity:0}}
@keyframes floatY{0%,100%{transform:translateY(0)}50%{transform:translateY(-7px)}}
input::placeholder{color:#5a6680}
pre::-webkit-scrollbar,div::-webkit-scrollbar{width:8px;height:8px}
pre::-webkit-scrollbar-thumb,div::-webkit-scrollbar-thumb{background:rgba(140,165,200,.18);border-radius:4px}
</style>
</helmet>
<div ref="{{ setRoot }}" style="position:fixed;inset:0;overflow:hidden;background:#0b0e13;font-family:'IBM Plex Sans',system-ui,sans-serif;color:#c7d2e0;cursor:{{ cursor }};user-select:none" onMouseDownCapture="{{ onRootDownCapture }}" onMouseDown="{{ onBgDown }}" onWheel="{{ onWheel }}">
<!-- ====== WORLD LAYER ====== -->
<div style="position:absolute;left:0;top:0;width:12000px;height:8000px;transform:{{ worldTransform }};transform-origin:0 0;transition:{{ worldTransition }};background-color:#0b0e13;background-image:linear-gradient(rgba(125,145,175,.04) 1px,transparent 1px),linear-gradient(90deg,rgba(125,145,175,.04) 1px,transparent 1px),linear-gradient(rgba(125,145,175,.075) 1px,transparent 1px),linear-gradient(90deg,rgba(125,145,175,.075) 1px,transparent 1px);background-size:34px 34px,34px 34px,170px 170px,170px 170px">
<!-- krate frames + headers -->
<sc-for list="{{ frames }}" as="f" hint-placeholder-count="0">
<div style="position:absolute;left:{{ f.left }}px;top:{{ f.top }}px;width:{{ f.w }}px;height:{{ f.h }}px;border:1px dashed {{ f.border }};border-radius:18px;background:{{ f.fill }};pointer-events:none"></div>
<div style="position:absolute;left:{{ f.labelLeft }}px;top:{{ f.labelTop }}px;display:flex;align-items:center;gap:9px;padding:6px 12px;border:1px solid {{ f.border }};border-radius:9px;background:rgba(15,19,27,.94);font-family:'IBM Plex Mono',monospace;font-size:13px;letter-spacing:.02em;color:#dbe3ef;cursor:grab" onMouseDown="{{ f.onDrag }}" onDoubleClick="{{ f.onToggleCollapse }}">
<span style="width:8px;height:8px;border-radius:2px;background:{{ f.color }}"></span>{{ f.label }}
<span style="font-size:10px;color:{{ f.statusColor }};border:1px solid {{ f.statusColor }};border-radius:5px;padding:1px 6px">{{ f.status }}</span>
<span style="font-size:11px;color:#6b7890">{{ f.count }} views</span>
<button onClick="{{ f.onCollapse }}" onMouseDown="{{ stopDown }}" title="collapse krate" style="border:none;background:transparent;color:#6b7890;font-size:15px;cursor:pointer;padding:0 0 0 2px;line-height:1"></button>
<button onClick="{{ f.onClose }}" onMouseDown="{{ stopDown }}" style="border:none;background:transparent;color:#6b7890;font-size:14px;cursor:pointer;padding:0 0 0 2px;line-height:1">×</button>
</div>
</sc-for>
<!-- collapsed krate bars -->
<sc-for list="{{ minis }}" as="m" hint-placeholder-count="0">
<div onDoubleClick="{{ m.onExpand }}" style="position:absolute;left:{{ m.x }}px;top:{{ m.y }}px;width:300px;display:flex;align-items:center;gap:10px;padding:11px 13px;border:1px solid {{ m.outline }};border-left:3px solid {{ m.color }};border-radius:11px;background:rgba(15,19,27,.97);box-shadow:0 14px 40px rgba(0,0,0,.5);cursor:grab" onMouseDown="{{ m.onDrag }}">
<span style="width:9px;height:9px;border-radius:2px;background:{{ m.color }};flex:none"></span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:13px;color:#e6edf6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">{{ m.label }}</span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:{{ m.statusColor }};flex:none">{{ m.count }} views</span>
<button onClick="{{ m.onExpand }}" onMouseDown="{{ stopDown }}" title="expand" style="border:none;background:transparent;color:#9fb0c8;font-size:13px;cursor:pointer;padding:0 2px;line-height:1"></button>
<button onClick="{{ m.onClose }}" onMouseDown="{{ stopDown }}" style="border:none;background:transparent;color:#6b7890;font-size:14px;cursor:pointer;padding:0;line-height:1">×</button>
</div>
</sc-for>
<sc-for list="{{ wins }}" as="w" hint-placeholder-count="0">
<div style="position:absolute;left:{{ w.x }}px;top:{{ w.y }}px;width:{{ w.w }}px;z-index:{{ w.z }};background:rgba(14,18,25,.98);border:1px solid {{ w.outline }};border-radius:11px;box-shadow:0 18px 50px rgba(0,0,0,.5);overflow:hidden;animation:popIn .16s ease" onMouseEnter="{{ w.onWinEnter }}" onMouseLeave="{{ w.onWinLeave }}" onMouseDown="{{ stopDown }}" onWheel="{{ winWheel }}" onDoubleClick="{{ stopDown }}">
<div style="display:flex;align-items:center;gap:9px;padding:9px 11px;border-bottom:1px solid rgba(140,165,200,.13);background:rgba(20,26,36,.7);cursor:grab" onMouseDown="{{ w.onDrag }}" onDoubleClick="{{ w.onFocus }}">
<span style="width:9px;height:9px;border-radius:2px;background:{{ w.color }};clip-path:{{ w.glyphClip }};flex:none"></span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:12.5px;color:#dbe3ef;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">{{ w.title }}</span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:{{ w.statusColor }};border:1px solid {{ w.statusColor }};border-radius:5px;padding:1px 6px;opacity:.9">{{ w.status }}</span>
<button onClick="{{ w.onFocus }}" onMouseDown="{{ stopDown }}" title="zoom to window (z)" style="border:none;background:transparent;color:#7e8aa2;font-size:12px;cursor:pointer;padding:0 2px;line-height:1"></button>
<button onClick="{{ w.onRelated }}" onMouseDown="{{ stopDown }}" title="open related" style="border:none;background:transparent;color:#7e8aa2;font-size:13px;cursor:pointer;padding:0 2px;line-height:1;display:{{ w.relatedDisplay }}"></button>
<button onClick="{{ w.onClose }}" onMouseDown="{{ stopDown }}" style="border:none;background:transparent;color:#7e8aa2;font-size:15px;cursor:pointer;padding:0 2px;line-height:1">×</button>
</div>
<sc-if value="{{ w.normal }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;gap:2px;padding:6px 8px 0;border-bottom:1px solid rgba(140,165,200,.1)">
<sc-for list="{{ w.tabs }}" as="t" hint-placeholder-count="4">
<button onClick="{{ t.onClick }}" onMouseDown="{{ stopDown }}" style="border:none;background:{{ t.bg }};color:{{ t.fg }};font-family:'IBM Plex Mono',monospace;font-size:11.5px;padding:6px 10px;border-radius:7px 7px 0 0;cursor:{{ t.cursor }};opacity:{{ t.opacity }}">{{ t.label }}</button>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ w.isText }}" hint-placeholder-val="{{ true }}">
<pre style="margin:0;padding:13px 15px;height:{{ w.bodyH }}px;overflow:auto;font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:1.65;color:#b9c6d8;white-space:pre-wrap;word-break:break-word;user-select:text;cursor:text">{{ w.body }}</pre>
</sc-if>
<sc-if value="{{ w.isLogs }}" hint-placeholder-val="{{ false }}">
<div ref="{{ w.logRef }}" onScroll="{{ w.onLogScroll }}" style="height:{{ w.bodyH }}px;overflow:auto;padding:12px 14px;background:#090c11;font-family:'IBM Plex Mono',monospace;font-size:11.5px;line-height:1.6;user-select:text;cursor:text">
<sc-for list="{{ w.logLines }}" as="ln" hint-placeholder-count="6"><div style="color:{{ ln.color }};white-space:pre-wrap">{{ ln.text }}</div></sc-for>
<div style="display:flex;align-items:center;gap:7px;color:#5a6680;font-size:10px;margin-top:7px"><span style="width:6px;height:6px;border-radius:50%;background:#4ad07a;animation:blink 1.2s ease-in-out infinite"></span>streaming…</div>
</div>
</sc-if>
<sc-if value="{{ w.isShell }}" hint-placeholder-val="{{ false }}">
<div onMouseEnter="{{ w.onShEnter }}" onMouseLeave="{{ w.onShLeave }}" style="height:{{ w.bodyH }}px;display:flex;flex-direction:column;background:#090c11">
<div ref="{{ w.shRef }}" onScroll="{{ w.onShScroll }}" style="flex:1;overflow:auto;padding:12px 14px;font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:1.6;user-select:text;cursor:text">
<sc-for list="{{ w.shellLines }}" as="ln" hint-placeholder-count="2"><div style="color:{{ ln.color }};white-space:pre-wrap;word-break:break-word">{{ ln.text }}</div></sc-for>
</div>
<div style="display:flex;align-items:center;gap:8px;padding:9px 13px;border-top:1px solid rgba(140,165,200,.12)">
<span style="color:{{ accent }};font-family:'IBM Plex Mono',monospace;font-size:11.5px;white-space:nowrap">{{ w.promptStr }}</span>
<input ref="{{ w.shInputRef }}" onKeyDown="{{ w.onShellKey }}" onMouseDown="{{ stopDown }}" placeholder="type a command…" spellcheck="false" autocomplete="off" style="flex:1;min-width:0;border:none;outline:none;background:transparent;color:#e6edf6;font-family:'IBM Plex Mono',monospace;font-size:12px"/>
</div>
</div>
</sc-if>
<sc-if value="{{ w.isCollection }}" hint-placeholder-val="{{ false }}">
<div onMouseEnter="{{ w.onCollEnter }}" onMouseLeave="{{ w.onCollLeave }}">
<div style="display:flex;align-items:center;gap:8px;padding:8px 11px;border-bottom:1px solid rgba(140,165,200,.1)">
<span style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:{{ accent }};flex:none"></span>
<input ref="{{ w.collInputRef }}" onChange="{{ w.onCollSearch }}" onKeyDown="{{ w.onCollKey }}" onMouseDown="{{ stopDown }}" value="{{ w.searchVal }}" placeholder="filter {{ w.total }} objects…" spellcheck="false" autocomplete="off" style="flex:1;min-width:0;border:none;outline:none;background:transparent;color:#e6edf6;font-family:'IBM Plex Mono',monospace;font-size:12px"/>
<span style="display:flex;align-items:center;gap:5px;padding:2px 8px;border-radius:6px;background:rgba(255,255,255,.03);border:1px solid rgba(140,165,200,.16);font-family:'IBM Plex Mono',monospace;font-size:9.5px;color:#7e8aa2;flex:none">↑↓ ⌥L/S/D/Y</span>
<div style="display:flex;gap:2px;background:rgba(8,11,16,.6);border:1px solid rgba(140,165,200,.16);border-radius:7px;padding:2px;flex:none">
<button onClick="{{ w.onCollList }}" onMouseDown="{{ stopDown }}" style="border:none;border-radius:5px;padding:3px 9px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:10.5px;background:{{ w.listBg }};color:{{ w.listFg }}">list</button>
<button onClick="{{ w.onCollTree }}" onMouseDown="{{ stopDown }}" style="border:none;border-radius:5px;padding:3px 9px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:10.5px;background:{{ w.treeBg }};color:{{ w.treeFg }}">tree</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:7px;padding:7px 12px;border-bottom:1px solid rgba(140,165,200,.08);font-family:'IBM Plex Mono',monospace;font-size:10px">
<span style="display:flex;align-items:center;gap:5px;color:#4ad07a"><span style="width:6px;height:6px;border-radius:50%;background:#4ad07a"></span>{{ w.summOk }} ok</span>
<span style="display:flex;align-items:center;gap:5px;color:#e8b54a"><span style="width:6px;height:6px;border-radius:50%;background:#e8b54a"></span>{{ w.summWarn }} pending</span>
<span style="display:flex;align-items:center;gap:5px;color:#ef6f6f"><span style="width:6px;height:6px;border-radius:50%;background:#ef6f6f"></span>{{ w.summBad }} degraded</span>
<span style="margin-left:auto;color:#6b7890">{{ w.count }} shown</span>
</div>
<div style="height:{{ w.bodyH }}px;overflow:auto;padding:6px">
<sc-for list="{{ w.rows }}" as="r" hint-placeholder-count="6">
<sc-if value="{{ r.isGroup }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:8px;padding:9px 9px 4px;margin-top:2px">
<span style="width:9px;height:9px;background:{{ r.color }};clip-path:{{ r.clip }};border-radius:{{ r.radius }};flex:none"></span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:#9fb0c8;flex:1">{{ r.groupLabel }}</span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#6b7890;flex:none">{{ r.groupCount }}</span>
</div>
</sc-if>
<sc-if value="{{ r.row }}" hint-placeholder-val="{{ true }}">
<div onClick="{{ r.onOpen }}" onMouseEnter="{{ r.onHover }}" onMouseDown="{{ stopDown }}" style="display:flex;align-items:center;gap:9px;padding:7px 9px 7px 7px;border-radius:8px;cursor:pointer;margin-left:{{ r.indentPx }}px;background:{{ r.selBg }};border-left:2px solid {{ r.selBar }}" style-hover="background:rgba(255,255,255,.04)">
<sc-if value="{{ r.child }}" hint-placeholder-val="{{ false }}"><span style="width:9px;height:9px;border-left:1px solid rgba(140,165,200,.35);border-bottom:1px solid rgba(140,165,200,.35);border-bottom-left-radius:3px;margin:0 -3px -3px 0;flex:none"></span></sc-if>
<span style="width:11px;height:11px;background:{{ r.color }};clip-path:{{ r.clip }};border-radius:{{ r.radius }};flex:none;filter:drop-shadow(0 0 3px {{ r.color }})"></span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:12px;color:#e6edf6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">{{ r.name }}</span>
<sc-if value="{{ r.hasRel }}" hint-placeholder-val="{{ false }}"><span style="font-family:'IBM Plex Mono',monospace;font-size:9px;color:#6b7890;flex:none">{{ r.rel }}</span></sc-if>
<span style="font-family:'IBM Plex Mono',monospace;font-size:9.5px;color:#7e8aa2;flex:none">{{ r.metric }}</span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:9.5px;color:#8493ab;border:1px solid rgba(140,165,200,.2);border-radius:4px;padding:1px 5px;flex:none">{{ r.typeShort }}</span>
<div style="display:flex;gap:3px;flex:none">
<sc-for list="{{ r.views }}" as="v" hint-placeholder-count="0">
<button onClick="{{ v.onClick }}" onMouseDown="{{ stopDown }}" title="open" style="display:flex;align-items:center;justify-content:center;width:18px;height:18px;border:1px solid rgba(140,165,200,.2);background:rgba(255,255,255,.03);border-radius:5px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:9px;color:#9fb0c8">{{ v.letter }}</button>
</sc-for>
</div>
<span style="width:7px;height:7px;border-radius:50%;background:{{ r.statusColor }};flex:none;box-shadow:0 0 6px {{ r.statusColor }}"></span>
</div>
</sc-if>
</sc-for>
<sc-if value="{{ w.empty }}" hint-placeholder-val="{{ false }}">
<div style="padding:22px;text-align:center;font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#6b7890">no objects match filter</div>
</sc-if>
</div>
</div>
</sc-if>
<div onMouseDown="{{ w.onResize }}" style="position:absolute;right:0;bottom:0;width:18px;height:18px;cursor:nwse-resize;border-right:2px solid rgba(140,165,200,.4);border-bottom:2px solid rgba(140,165,200,.4);border-bottom-right-radius:9px"></div>
</div>
</sc-for>
</div>
<!-- ====== COLLAPSED CARDS (screen space) ====== -->
<sc-for list="{{ cards }}" as="c" hint-placeholder-count="0">
<div style="position:absolute;left:{{ c.sx }}px;top:{{ c.sy }}px;width:230px;background:rgba(15,19,27,.97);border:1px solid {{ c.outline }};border-radius:12px;box-shadow:0 16px 44px rgba(0,0,0,.5);overflow:hidden;cursor:grab;z-index:{{ c.z }};transition:{{ c.trans }};animation:popIn .16s ease" onMouseDown="{{ c.onDown }}" onDoubleClick="{{ c.onExpand }}">
<div style="display:flex;align-items:center;gap:8px;padding:11px 13px 9px">
<span style="width:9px;height:9px;border-radius:2px;background:{{ c.color }};flex:none"></span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:13px;color:#e6edf6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">{{ c.label }}</span>
<span style="width:7px;height:7px;border-radius:50%;background:{{ c.statusColor }};flex:none;box-shadow:0 0 7px {{ c.statusColor }}"></span>
<button onClick="{{ c.onClose }}" onMouseDown="{{ stopDown }}" style="border:none;background:transparent;color:#6b7890;font-size:14px;cursor:pointer;padding:0;line-height:1;flex:none">×</button>
</div>
<div style="display:flex;gap:5px;padding:0 13px 11px;flex-wrap:wrap">
<sc-for list="{{ c.badges }}" as="b" hint-placeholder-count="2">
<span style="display:flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:{{ c.tint }};border:1px solid {{ c.outline }};font-family:'IBM Plex Mono',monospace;font-size:11px;color:{{ c.color }}">{{ b }}</span>
</sc-for>
</div>
<div style="padding:8px 13px;border-top:1px solid rgba(140,165,200,.1);background:rgba(8,11,16,.5);font-family:'IBM Plex Mono',monospace;font-size:10px;color:#7e8aa2;display:flex;justify-content:space-between">
<span>{{ c.count }} windows · ns/{{ c.ns }}</span><span style="color:#5a6680">⤢ drag · dbl-click</span>
</div>
</div>
</sc-for>
<!-- ====== RELATED MENU ====== -->
<sc-if value="{{ relMenu }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;left:{{ relMenu.sx }}px;top:{{ relMenu.sy }}px;width:236px;background:rgba(16,20,28,.98);border:1px solid rgba(140,165,200,.24);border-radius:11px;box-shadow:0 22px 60px rgba(0,0,0,.6);overflow:hidden;z-index:55;animation:popIn .14s ease" onMouseDown="{{ stopDown }}" onWheel="{{ stopDown }}">
<div style="padding:9px 13px;font-family:'IBM Plex Mono',monospace;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:#6b7890;border-bottom:1px solid rgba(140,165,200,.1)">related to {{ relMenu.label }}</div>
<div style="max-height:300px;overflow:auto">
<sc-for list="{{ relMenu.items }}" as="it" hint-placeholder-count="3">
<div onClick="{{ it.onClick }}" onMouseDown="{{ stopDown }}" style="display:flex;align-items:center;gap:11px;padding:9px 13px;cursor:pointer" style-hover="background:rgba(255,255,255,.045)">
<span style="width:13px;height:13px;background:{{ it.color }};clip-path:{{ it.clip }};border-radius:{{ it.radius }};flex:none;filter:drop-shadow(0 0 4px {{ it.color }})"></span>
<span style="flex:1;min-width:0;font-family:'IBM Plex Mono',monospace;font-size:12.5px;color:#e6edf6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ it.name }}</span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#8493ab;border:1px solid rgba(140,165,200,.2);border-radius:5px;padding:1px 6px;flex:none">{{ it.typeShort }}</span>
</div>
</sc-for>
</div>
<sc-if value="{{ relMenu.empty }}" hint-placeholder-val="{{ false }}">
<div style="padding:16px;text-align:center;font-family:'IBM Plex Mono',monospace;font-size:11px;color:#6b7890">no related objects</div>
</sc-if>
</div>
</sc-if>
<!-- ====== EMPTY STATE ====== -->
<sc-if value="{{ emptyCanvas }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;left:50%;top:47%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;gap:20px;pointer-events:none;text-align:center;width:480px">
<div style="width:50px;height:50px;background:{{ accent }};clip-path:polygon(50% 0,100% 50%,50% 100%,0 50%);filter:drop-shadow(0 0 22px {{ accentGlow }});animation:floatY 4s ease-in-out infinite"></div>
<div style="font-family:'IBM Plex Sans',sans-serif;font-size:25px;color:#e8eef6;font-weight:500;letter-spacing:-.01em">Search your cluster</div>
<div style="font-family:'IBM Plex Mono',monospace;font-size:13px;color:#7e8aa2;line-height:1.75">Start typing or click anywhere. Pick a result, then <span style="color:{{ accent }}">⌥L</span> logs · <span style="color:{{ accent }}">⌥S</span> shell · <span style="color:{{ accent }}">⌥D</span> describe · <span style="color:{{ accent }}">⌥Y</span> yaml to open the views you want, grouped on the canvas.</div>
</div>
</sc-if>
<!-- ====== TOP BAR ====== -->
<div style="position:absolute;top:0;left:0;right:0;display:flex;align-items:center;gap:14px;padding:13px 18px;pointer-events:none">
<div style="display:flex;align-items:center;gap:10px;pointer-events:auto">
<div style="display:flex;align-items:center;gap:8px;padding:7px 12px;background:rgba(14,18,25,.82);border:1px solid rgba(140,165,200,.18);border-radius:9px;backdrop-filter:blur(6px)">
<span style="width:8px;height:8px;background:{{ accent }};clip-path:polygon(50% 0,100% 50%,50% 100%,0 50%)"></span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:13px;letter-spacing:.06em;color:#e3eaf4;font-weight:500">krates</span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:#6b7890">/ yard</span>
</div>
<div style="display:flex;align-items:center;gap:7px;padding:7px 12px;background:rgba(14,18,25,.82);border:1px solid rgba(140,165,200,.18);border-radius:9px;backdrop-filter:blur(6px);font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#9fb0c8">
<span style="width:6px;height:6px;border-radius:50%;background:#4ad07a;box-shadow:0 0 8px #4ad07a"></span>prod-eu-1
</div>
<sc-if value="{{ krateCount }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:7px;padding:7px 12px;background:rgba(14,18,25,.82);border:1px solid rgba(140,165,200,.18);border-radius:9px;backdrop-filter:blur(6px);font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#7e8aa2">{{ krateCount }} krates</div>
</sc-if>
</div>
<div style="flex:1"></div>
<div style="display:flex;align-items:center;gap:9px;pointer-events:auto">
<div style="display:flex;align-items:center;gap:6px;padding:6px 11px;background:rgba(14,18,25,.82);border:1px solid rgba(140,165,200,.18);border-radius:9px;backdrop-filter:blur(6px);font-family:'IBM Plex Mono',monospace;font-size:11px;color:#7e8aa2">
<span style="width:6px;height:6px;border-radius:50%;background:{{ accent }};animation:blink 1.6s ease-in-out infinite"></span>synced
</div>
<button onClick="{{ onToggleAdmin }}" onMouseDown="{{ stopDown }}" style="display:flex;align-items:center;gap:6px;padding:6px 11px;background:{{ adminBtnBg }};border:1px solid {{ adminBtnBorder }};border-radius:9px;backdrop-filter:blur(6px);font-family:'IBM Plex Mono',monospace;font-size:11px;color:{{ adminBtnFg }};cursor:pointer">◉ admin</button>
<div style="display:flex;align-items:center">
<sc-for list="{{ roster }}" as="u" hint-placeholder-count="4">
<div title="{{ u.name }}" style="width:30px;height:30px;margin-left:-7px;border-radius:50%;background:{{ u.color }};border:2px solid #0b0e13;display:flex;align-items:center;justify-content:center;font-family:'IBM Plex Mono',monospace;font-size:11px;font-weight:600;color:#0b0e13">{{ u.initial }}</div>
</sc-for>
</div>
</div>
</div>
<!-- ====== BOTTOM HINT ====== -->
<div style="position:absolute;right:18px;bottom:18px;display:flex;align-items:center;gap:13px;padding:9px 14px;background:rgba(14,18,25,.8);border:1px solid rgba(140,165,200,.16);border-radius:10px;backdrop-filter:blur(6px);font-family:'IBM Plex Mono',monospace;font-size:11px;color:#7e8aa2;pointer-events:none">
<span><span style="color:#aeb9cc">click / type</span> search</span>
<span style="opacity:.4">·</span>
<span><span style="color:#aeb9cc">scroll</span> pan</span>
<span style="opacity:.4">·</span>
<span><span style="color:#aeb9cc">⌘/ctrl + scroll</span> zoom</span>
<span style="opacity:.4">·</span>
<span><span style="color:#aeb9cc">space-drag</span> pan</span>
<span style="opacity:.4">·</span>
<span><span style="color:#aeb9cc">z</span> zoom window</span>
</div>
<!-- ====== ZOOM PILL ====== -->
<div style="position:absolute;left:18px;bottom:18px;display:flex;align-items:center;gap:9px;padding:8px 13px;background:rgba(14,18,25,.8);border:1px solid rgba(140,165,200,.16);border-radius:10px;backdrop-filter:blur(6px);font-family:'IBM Plex Mono',monospace;font-size:11px;color:#9fb0c8;pointer-events:none">
<span style="width:6px;height:6px;border-radius:50%;background:{{ zoomDotColor }}"></span>{{ zoomLabel }}
</div>
<!-- ====== MINIMAP ====== -->
<sc-if value="{{ minimap }}" hint-placeholder-val="{{ false }}">
<div ref="{{ minimap.setEl }}" onMouseDown="{{ minimap.onDown }}" style="position:absolute;right:18px;bottom:64px;width:{{ minimap.w }}px;height:{{ minimap.h }}px;background:rgba(11,14,19,.92);border:1px solid rgba(140,165,200,.2);border-radius:10px;backdrop-filter:blur(6px);overflow:hidden;cursor:pointer;box-shadow:0 12px 36px rgba(0,0,0,.5)">
<div style="position:absolute;left:6px;top:5px;font-family:'IBM Plex Mono',monospace;font-size:8.5px;letter-spacing:.12em;text-transform:uppercase;color:#5a6680;pointer-events:none">map</div>
<sc-for list="{{ minimap.rects }}" as="r" hint-placeholder-count="0">
<div style="position:absolute;left:{{ r.x }}px;top:{{ r.y }}px;width:{{ r.w }}px;height:{{ r.h }}px;background:{{ r.color }};opacity:.55;border-radius:2px;pointer-events:none"></div>
</sc-for>
<div style="position:absolute;left:{{ minimap.vx }}px;top:{{ minimap.vy }}px;width:{{ minimap.vw }}px;height:{{ minimap.vh }}px;border:1px solid rgba(230,237,246,.85);border-radius:2px;background:rgba(230,237,246,.06);pointer-events:none"></div>
</div>
</sc-if>
<!-- ====== ADMIN DRAWER ====== -->
<sc-if value="{{ admin }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:0;background:rgba(7,9,13,.4);z-index:55;animation:fadeIn .12s ease" onMouseDown="{{ onToggleAdmin }}"></div>
<div style="position:absolute;top:0;right:0;bottom:0;width:380px;background:rgba(13,17,24,.98);border-left:1px solid rgba(140,165,200,.2);box-shadow:-20px 0 60px rgba(0,0,0,.5);z-index:56;display:flex;flex-direction:column;animation:fadeIn .16s ease" onMouseDown="{{ stopDown }}">
<div style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid rgba(140,165,200,.14)">
<span style="width:9px;height:9px;border-radius:50%;background:{{ accent }};box-shadow:0 0 8px {{ accent }}"></span>
<div style="flex:1"><div style="font-family:'IBM Plex Mono',monospace;font-size:14px;color:#e8eef6">Yard activity</div><div style="font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:#7e8aa2;margin-top:2px">{{ adminCount }} connected · prod-eu-1</div></div>
<button onClick="{{ onToggleAdmin }}" onMouseDown="{{ stopDown }}" style="border:none;background:transparent;color:#7e8aa2;font-size:17px;cursor:pointer;line-height:1">×</button>
</div>
<div style="flex:1;overflow:auto;padding:12px">
<sc-for list="{{ adminUsers }}" as="u" hint-placeholder-count="4">
<div style="margin-bottom:12px;border:1px solid {{ u.border }};border-radius:12px;background:rgba(255,255,255,.02);overflow:hidden">
<div style="display:flex;align-items:center;gap:10px;padding:11px 13px">
<div style="width:30px;height:30px;border-radius:50%;background:{{ u.color }};display:flex;align-items:center;justify-content:center;font-family:'IBM Plex Mono',monospace;font-size:11px;font-weight:600;color:#0b0e13;flex:none">{{ u.initial }}</div>
<div style="flex:1;min-width:0"><div style="font-family:'IBM Plex Mono',monospace;font-size:12.5px;color:#e6edf6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ u.name }}</div><div style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:{{ u.statusColor }};margin-top:1px">{{ u.status }}</div></div>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#7e8aa2;flex:none">{{ u.krateCount }} krates</span>
</div>
<div style="display:flex;align-items:center;gap:8px;padding:8px 13px;background:rgba(8,11,16,.5);border-top:1px solid rgba(140,165,200,.1)">
<span style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:{{ accent }};flex:none"></span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#aeb9cc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">{{ u.query }}</span>
<sc-if value="{{ u.typing }}" hint-placeholder-val="{{ false }}"><span style="font-family:'IBM Plex Mono',monospace;font-size:9px;color:{{ accent }};flex:none">typing…</span></sc-if>
</div>
<div style="display:flex;flex-direction:column;gap:1px;padding:4px 6px 7px">
<sc-for list="{{ u.krates }}" as="kr" hint-placeholder-count="2">
<div onClick="{{ kr.onSpectate }}" onMouseDown="{{ stopDown }}" style="display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:8px;cursor:pointer" style-hover="background:rgba(255,255,255,.04)">
<span style="width:8px;height:8px;border-radius:2px;background:{{ kr.color }};flex:none"></span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#dbe3ef;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">{{ kr.label }}</span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:9px;color:#7e8aa2;border:1px solid rgba(140,165,200,.2);border-radius:4px;padding:1px 5px;flex:none">{{ kr.badges }}</span>
<span style="width:6px;height:6px;border-radius:50%;background:{{ kr.statusColor }};flex:none"></span>
</div>
</sc-for>
</div>
</div>
</sc-for>
</div>
</div>
</sc-if>
<!-- ====== SPOTLIGHT ====== -->
<sc-if value="{{ spotOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute;inset:0;background:rgba(7,9,13,.34);z-index:60;animation:fadeIn .12s ease" onMouseDown="{{ onSpotBackdrop }}">
<div style="position:absolute;left:50%;top:14%;transform:translateX(-50%);width:min(660px,93vw);animation:spotIn .16s cubic-bezier(.2,.8,.3,1)" onMouseDown="{{ stopDown }}">
<div style="background:rgba(16,20,28,.97);border:1px solid rgba(140,165,200,.26);border-radius:14px;box-shadow:0 32px 90px rgba(0,0,0,.65),0 0 0 1px rgba(0,0,0,.5);overflow:hidden">
<div style="display:flex;align-items:center;gap:11px;padding:15px 17px">
<span style="font-family:'IBM Plex Mono',monospace;font-size:17px;color:{{ accent }};flex:none"></span>
<sc-if value="{{ hasFilter }}" hint-placeholder-val="{{ false }}">
<span style="display:flex;align-items:center;gap:6px;padding:4px 9px;background:{{ accentDim }};border:1px solid {{ accent }};border-radius:7px;font-family:'IBM Plex Mono',monospace;font-size:12.5px;color:{{ accent }};flex:none"><span style="width:8px;height:8px;background:{{ filterColor }};clip-path:{{ filterClip }};border-radius:{{ filterRadius }}"></span>{{ filterLabel }}<span style="color:#6b7890;font-size:11px"></span></span>
</sc-if>
<input ref="{{ setInput }}" value="{{ query }}" onChange="{{ onSearchChange }}" onKeyDown="{{ onSearchKeydown }}" placeholder="{{ placeholder }}" style="flex:1;min-width:0;border:none;outline:none;background:transparent;color:#eef3fa;font-family:'IBM Plex Sans',sans-serif;font-size:18px"/>
<sc-if value="{{ ghost }}" hint-placeholder-val="{{ false }}">
<span style="display:flex;align-items:center;gap:7px;padding:4px 9px;border:1px dashed {{ accent }};border-radius:7px;font-family:'IBM Plex Mono',monospace;font-size:12px;color:{{ accent }};flex:none;white-space:nowrap">⇥ {{ ghost }}</span>
</sc-if>
<sc-if value="{{ sessionActive }}" hint-placeholder-val="{{ false }}">
<span style="display:flex;align-items:center;gap:6px;flex:none">
<sc-for list="{{ buildingViews }}" as="bv" hint-placeholder-count="1"><span style="display:flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:5px;background:{{ accent }};color:#0b0e13;font-family:'IBM Plex Mono',monospace;font-size:10px;font-weight:600">{{ bv }}</span></sc-for>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#6b7890;white-space:nowrap">esc/idle places</span>
</span>
</sc-if>
</div>
<div style="display:flex;gap:6px;padding:0 16px 12px;flex-wrap:wrap;border-bottom:1px solid rgba(140,165,200,.1)">
<sc-for list="{{ chips }}" as="c" hint-placeholder-count="7">
<button onClick="{{ c.onClick }}" onMouseDown="{{ stopDown }}" style="display:flex;align-items:center;gap:6px;border:1px solid {{ c.border }};background:{{ c.bg }};color:{{ c.fg }};font-family:'IBM Plex Mono',monospace;font-size:11px;padding:4px 9px;border-radius:7px;cursor:pointer">
<span style="width:8px;height:8px;background:{{ c.color }};clip-path:{{ c.clip }};border-radius:{{ c.radius }};flex:none"></span>{{ c.label }}
</button>
</sc-for>
</div>
<div style="max-height:48vh;overflow:auto;padding:8px">
<div style="font-family:'IBM Plex Mono',monospace;font-size:9.5px;letter-spacing:.14em;color:#6b7890;text-transform:uppercase;padding:6px 9px 8px">{{ resultsHeader }}</div>
<sc-for list="{{ results }}" as="r" hint-placeholder-count="6">
<div onMouseEnter="{{ r.onHover }}" onMouseDown="{{ stopDown }}" onClick="{{ r.onClick }}" style="border-radius:9px;background:{{ r.bg }};margin-bottom:2px;cursor:pointer">
<div style="display:flex;align-items:center;gap:12px;padding:10px 11px">
<span style="width:16px;height:16px;background:{{ r.color }};clip-path:{{ r.clip }};border-radius:{{ r.radius }};flex:none;filter:drop-shadow(0 0 4px {{ r.glow }})"></span>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px">
<span style="font-family:'IBM Plex Mono',monospace;font-size:13.5px;color:#e6edf6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.name }}</span>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:#7e8aa2">{{ r.sub }}</span>
</div>
<sc-if value="{{ r.isCrd }}" hint-placeholder-val="{{ false }}">
<span style="font-family:'IBM Plex Mono',monospace;font-size:9px;letter-spacing:.1em;color:#ffd479;border:1px solid rgba(255,212,121,.4);border-radius:5px;padding:2px 6px">CRD</span>
</sc-if>
<sc-if value="{{ r.showBadge }}" hint-placeholder-val="{{ false }}">
<span style="font-family:'IBM Plex Mono',monospace;font-size:9px;letter-spacing:.1em;color:{{ accent }};border:1px solid {{ accentDim }};background:{{ accentDim }};border-radius:5px;padding:2px 6px;flex:none">{{ r.badgeLabel }}</span>
</sc-if>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:#8493ab;border:1px solid rgba(140,165,200,.2);border-radius:5px;padding:2px 7px;flex:none">{{ r.typeShort }}</span>
</div>
<sc-if value="{{ r.active }}" hint-placeholder-val="{{ false }}">
<div style="display:flex;align-items:center;gap:7px;padding:0 11px 11px 39px;flex-wrap:wrap">
<sc-if value="{{ r.enter }}" hint-placeholder-val="{{ false }}">
<span style="display:flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:5px;background:{{ accent }};color:#0b0e13;font-family:'IBM Plex Mono',monospace;font-size:11px;font-weight:600"></span>
</sc-if>
<sc-for list="{{ r.views }}" as="v" hint-placeholder-count="4">
<button onClick="{{ v.onClick }}" onMouseDown="{{ stopDown }}" style="display:flex;align-items:center;gap:7px;border:1px solid {{ v.border }};background:{{ v.bg }};color:{{ v.fg }};font-family:'IBM Plex Mono',monospace;font-size:11.5px;padding:5px 10px 5px 5px;border-radius:8px;cursor:{{ v.cursor }};opacity:{{ v.opacity }}">
<span style="display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:5px;background:{{ v.kbdBg }};color:{{ v.kbdFg }};font-size:10px;font-weight:600">{{ v.letter }}</span>{{ v.label }}{{ v.mark }}
</button>
</sc-for>
<span style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#5a6680;margin-left:2px">{{ r.viewHint }}</span>
</div>
</sc-if>
</div>
</sc-for>
<sc-if value="{{ noResults }}" hint-placeholder-val="{{ false }}">
<div style="padding:24px 11px;text-align:center;font-family:'IBM Plex Mono',monospace;font-size:12px;color:#6b7890">no objects match "{{ query }}"</div>
</sc-if>
</div>
<div style="display:flex;align-items:center;gap:15px;padding:9px 16px;border-top:1px solid rgba(140,165,200,.1);background:rgba(10,13,19,.6);font-family:'IBM Plex Mono',monospace;font-size:10.5px;color:#6b7890">
<span><span style="color:#9fb0c8">↑↓</span> pick</span>
<span><span style="color:#9fb0c8">{{ openKeysLabel }}</span> open views</span>
<span><span style="color:#9fb0c8"></span> {{ enterLabel }}</span>
<span><span style="color:#9fb0c8"></span> cycle type</span>
<span style="margin-left:auto"><span style="color:#9fb0c8">esc</span> done</span>
</div>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;accent&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;cyan&quot;,&quot;violet&quot;,&quot;amber&quot;],&quot;default&quot;:&quot;cyan&quot;,&quot;tsType&quot;:&quot;string&quot;}}">
class Component extends DCLogic {
constructor(props){
super(props);
this.world = this.genWorld();
this.adj = {};
this.world.links.forEach(l=>{ (this.adj[l.from]=this.adj[l.from]||[]).push(l.to); (this.adj[l.to]=this.adj[l.to]||[]).push(l.from); });
this.byId = {}; this.world.nodes.forEach(n=>this.byId[n.id]=n);
this.state = { camX:140, camY:110, zoom:.92, flying:false, dragging:false, collapsed:false,
spotOpen:false, query:'', filterType:null, sel:0, navigated:false,
krates:[], krateSeq:0, session:null, logs:{}, sh:{}, relMenu:null, cardDrag:null, collSearch:{}, collView:{}, collSel:{}, collNav:{}, zoomedWin:null, admin:false };
this._logEls={}; this._stickLog={}; this._shEls={}; this._stickSh={}; this._shInputEls={}; this._hoverShWid=null; this._space=false;
this._collInputEls={}; this._hoverCollWid=null; this._typeUse={}; this._recentTypes=[]; this._hoverWinId=null; this._collCtx={}; this._collNavTimer={};
}
componentDidMount(){ this._onKey=(e)=>this.handleGlobalKey(e); this._onKeyUp=(e)=>{ if(e.key===' '){ this._space=false; if(this.state.spacePan) this.setState({spacePan:false}); } };
document.addEventListener('keydown', this._onKey); document.addEventListener('keyup', this._onKeyUp);
this._stream=setInterval(()=>this.appendLogs(), 1500); }
componentWillUnmount(){ document.removeEventListener('keydown', this._onKey); document.removeEventListener('keyup', this._onKeyUp); this.clearTimers(); clearInterval(this._stream); }
clearTimers(){ clearTimeout(this._arm); clearTimeout(this._idle); }
armSoon(){ clearTimeout(this._arm); this._arm=setTimeout(()=>{ if(this.state.spotOpen && !this.state.session && !this.state.navigated && this.buildResults(this.state.query,this.state.filterType).length){ this.setState({navigated:true}); } }, 460); }
armDismiss(){ clearTimeout(this._idle); this._idle=setTimeout(()=>{ if(this.state.spotOpen && this.state.session){ this.finishSpotlight(); } }, 520); }
componentDidUpdate(){ if(this._focus && this.inputEl){ this.inputEl.focus(); const v=this.inputEl.value; try{this.inputEl.setSelectionRange(v.length,v.length);}catch(e){} this._focus=false; }
Object.keys(this._logEls).forEach(wid=>{ const el=this._logEls[wid]; if(el && el.isConnected && this._stickLog[wid]!==false) el.scrollTop=el.scrollHeight; });
Object.keys(this._shEls).forEach(wid=>{ const el=this._shEls[wid]; if(el && el.isConnected && this._stickSh[wid]!==false) el.scrollTop=el.scrollHeight; });
if(this._focusCollWid){ const el=this._collInputEls[this._focusCollWid]; if(el && el.isConnected){ try{ el.focus(); }catch(e){} this._focusCollWid=null; } } }
// ---------- data ----------
genWorld(){
const nodes=[], links=[], namespaces=[]; let uid=0; const nid=(p)=>p+'-'+(uid++);
const defs=[
{ name:'payments', color:'#6fb1ff', primaries:[
{type:'deployment',name:'api-gateway',img:'krates/gateway:1.8.2',replicas:'3/3',children:[
{type:'pod',name:'api-gateway-7c4f-x2q'},{type:'pod',name:'api-gateway-7c4f-9kp'},{type:'pod',name:'api-gateway-7c4f-lph'},
{type:'configmap',name:'gateway-config'},{type:'secret',name:'gateway-tls',secret:{'tls.crt':'-----BEGIN CERTIFICATE-----','tls.key':'-----BEGIN PRIVATE KEY-----'}}]},
{type:'service',name:'api-gateway',relTo:'api-gateway',port:'443:8443'},
{type:'deployment',name:'ledger',img:'krates/ledger:2.3.0',replicas:'1/2',status:'Degraded',children:[
{type:'pod',name:'ledger-58d9-aa1'},{type:'pod',name:'ledger-58d9-bb2',status:'Pending'},
{type:'configmap',name:'ledger-config'},{type:'secret',name:'db-credentials',secret:{'username':'payments','password':'s3cr3t-pg!'}}]},
{type:'service',name:'ledger',relTo:'ledger',port:'80:8080'},
{type:'statefulset',name:'postgres',img:'postgres:16',replicas:'2/2',children:[
{type:'pod',name:'postgres-0'},{type:'pod',name:'postgres-1'},
{type:'pvc',name:'pgdata-postgres-0'},{type:'pvc',name:'pgdata-postgres-1'}]},
{type:'crd',name:'paymentroute/checkout',kindName:'PaymentRoute',relTo:'api-gateway'} ]},
{ name:'platform', color:'#5fd0c0', primaries:[
{type:'deployment',name:'auth',img:'krates/auth:4.1.1',replicas:'2/2',children:[
{type:'pod',name:'auth-9f7c-m1'},{type:'pod',name:'auth-9f7c-m2'},
{type:'configmap',name:'auth-config'},{type:'secret',name:'oauth-secret',secret:{'client_id':'krates-web','client_secret':'oa_7xK29fLp'}}]},
{type:'service',name:'auth',relTo:'auth',port:'80:9000'},
{type:'ingress',name:'platform-ingress',relTo:'auth'},
{type:'daemonset',name:'node-exporter',img:'prom/node-exporter:1.7',replicas:'5/5',children:[
{type:'pod',name:'node-exporter-tz4'},{type:'pod',name:'node-exporter-q8r'}]},
{type:'crd',name:'certificate/platform-tls',kindName:'Certificate',relTo:'platform-ingress'} ]},
{ name:'storefront', color:'#e58fb0', primaries:[
{type:'deployment',name:'web',img:'krates/web:7.0.4',replicas:'3/3',children:[
{type:'pod',name:'web-6b5c-aa'},{type:'pod',name:'web-6b5c-bb'},{type:'pod',name:'web-6b5c-cc'},
{type:'configmap',name:'web-config'}]},
{type:'service',name:'web',relTo:'web',port:'80:3000'},
{type:'deployment',name:'cart',img:'krates/cart:3.2.0',replicas:'2/2',children:[
{type:'pod',name:'cart-77d-aa'},{type:'pod',name:'cart-77d-bb'},
{type:'secret',name:'stripe-key',secret:{'STRIPE_SECRET':'sk_live_4eC39HqLy'}}]},
{type:'statefulset',name:'redis',img:'redis:7.2',replicas:'1/1',children:[
{type:'pod',name:'redis-0'},{type:'pvc',name:'redis-data-redis-0'}]} ]},
];
defs.forEach(d=>{ const nameMap={};
d.primaries.forEach(p=>{ const node={id:nid(p.type),type:p.type,name:p.name,ns:d.name,status:p.status||'Ready',data:p}; nodes.push(node); (nameMap[p.name]=nameMap[p.name]||[]).push(node); p._n=node;
(p.children||[]).forEach(c=>{ const cn={id:nid(c.type),type:c.type,name:c.name,ns:d.name,status:c.status||'Running',data:c}; nodes.push(cn); links.push({from:node.id,to:cn.id}); }); });
d.primaries.forEach(p=>{ if(!p.relTo) return; const tg=(nameMap[p.relTo]||[]).find(x=>x.id!==p._n.id); if(tg) links.push({from:p._n.id,to:tg.id}); });
namespaces.push({name:d.name,color:d.color});
});
return {nodes,links,namespaces};
}
meta(t){ const m={
deployment:{label:'Deployment',short:'deploy',color:'#6fb1ff',shape:'circle'},
statefulset:{label:'StatefulSet',short:'sts',color:'#b89cff',shape:'triangle'},
service:{label:'Service',short:'svc',color:'#5fd0c0',shape:'diamond'},
daemonset:{label:'DaemonSet',short:'ds',color:'#ffb27a',shape:'ring'},
ingress:{label:'Ingress',short:'ing',color:'#e58fb0',shape:'triangle'},
crd:{label:'Custom Resource',short:'crd',color:'#ffd479',shape:'hexagon',crd:true},
pod:{label:'Pod',short:'pod',color:'#8aa0bd',shape:'circle'},
configmap:{label:'ConfigMap',short:'cm',color:'#7fb39c',shape:'square'},
secret:{label:'Secret',short:'secret',color:'#c79bd6',shape:'hexagon'},
pvc:{label:'PVC',short:'pvc',color:'#9aa7c7',shape:'square'} };
return m[t]||{label:t,short:t,color:'#8aa0bd',shape:'circle'}; }
shapeCss(shape){ if(shape==='triangle') return {clip:'polygon(50% 0,100% 100%,0 100%)',radius:'0'};
if(shape==='diamond') return {clip:'polygon(50% 0,100% 50%,50% 100%,0 50%)',radius:'0'};
if(shape==='hexagon') return {clip:'polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%)',radius:'0'};
if(shape==='square') return {clip:'none',radius:'3px'};
return {clip:'none',radius:'50%'}; }
statusColor(s){ return s==='Degraded'?'#ef6f6f':(s==='Pending'?'#e8b54a':(s==='Failed'?'#ef6f6f':'#4ad07a')); }
rgba(hex,a){ const h=hex.replace('#',''); const n=parseInt(h.length===3?h.split('').map(c=>c+c).join(''):h,16); return 'rgba('+((n>>16)&255)+','+((n>>8)&255)+','+(n&255)+','+a+')'; }
accentSet(){ const a=this.props.accent||'cyan'; const map={cyan:'#4dd6e8',violet:'#a98cff',amber:'#f5b454'}; const c=map[a]||map.cyan; return {accent:c,glow:this.rgba(c,.5),dim:this.rgba(c,.14)}; }
// ---------- views ----------
viewMeta(){ return {logs:{letter:'L',label:'logs'},shell:{letter:'S',label:'shell'},describe:{letter:'D',label:'describe'},yaml:{letter:'Y',label:'yaml'}}; }
viewOrder(){ return ['logs','shell','describe','yaml']; }
execable(type){ return ['pod','deployment','statefulset','daemonset'].includes(type); }
viewAvail(type,view){ if(view==='yaml'||view==='describe') return true; if(view==='logs'||view==='shell') return this.execable(type); return false; }
defaultView(type){ if(type==='pod') return 'logs'; if(['deployment','statefulset','daemonset'].includes(type)) return 'describe'; return 'yaml'; }
keyToView(k){ return ({l:'logs',s:'shell',d:'describe',y:'yaml'})[k]; }
codeLetter(e){ if(e.code && e.code.indexOf('Key')===0) return e.code.slice(3).toLowerCase(); const k=(e.key||''); return k.length===1?k.toLowerCase():''; }
// ---------- search ----------
aliases(){ return [
{k:['deploy','deployment','deployments','dep'],t:'deployment'},{k:['sts','statefulset','statefulsets'],t:'statefulset'},
{k:['svc','service','services'],t:'service'},{k:['ds','daemonset','daemonsets'],t:'daemonset'},
{k:['ing','ingress','ingresses'],t:'ingress'},{k:['crd','customresource','cr'],t:'crd'},
{k:['po','pod','pods'],t:'pod'},{k:['cm','configmap','configmaps','config'],t:'configmap'},
{k:['secret','secrets','sealed'],t:'secret'},{k:['pvc','pv','volume','volumes'],t:'pvc'} ]; }
suggestType(q,filter){ if(filter) return null; const s=q.trim().toLowerCase(); if(s.length<2) return null;
for(const a of this.aliases()){ for(const k of a.k){ if(k.startsWith(s)||s===k) return a.t; } } return null; }
fuzzy(q,text){ if(!q) return 0; q=q.toLowerCase(); const t=text.toLowerCase(); let qi=0,score=0,prev=-2;
for(let i=0;i<t.length && qi<q.length;i++){ if(t[i]===q[qi]){ let pts=1; if(i===prev+1) pts+=3; if(i===0||/[^a-z0-9]/.test(t[i-1])) pts+=5; score+=pts; prev=i; qi++; } }
return qi===q.length?score:-1; }
boost(t){ const m={crd:60,deployment:50,statefulset:48,service:46,daemonset:44,ingress:42,secret:24,configmap:22,pvc:18,pod:16}; return m[t]||10; }
// ---------- collections ----------
catMembers(type){ return this.world.nodes.filter(n=>n.type===type); }
nsMembers(ns){ return this.world.nodes.filter(n=>n.ns===ns); }
collMembers(scope){ return scope.kind==='namespace'? this.nsMembers(scope.value) : this.catMembers(scope.value); }
collTitle(scope){ return scope.kind==='namespace'? ('ns/'+scope.value) : ('All '+this.meta(scope.value).label+'s'); }
statusSummary(nodes){ const c={}; nodes.forEach(n=>{ c[n.status]=(c[n.status]||0)+1; }); const ok=(c.Ready||0)+(c.Running||0); const warn=(c.Pending||0); const bad=(c.Degraded||0)+(c.Failed||0); return {ok,warn,bad,total:nodes.length}; }
healthMetric(n){ const d=n.data||{}; if(d.replicas) return d.replicas+' ready'; if(n.type==='pod') return 'restarts '+(n.name.length%4); if(n.type==='pvc') return '20Gi · gp3'; if(n.type==='service') return d.port||'ClusterIP'; if(n.type==='secret') return 'Opaque · '+Object.keys(d.secret||{}).length+' keys'; if(n.type==='configmap') return '3 keys'; if(n.type==='ingress') return n.ns+'.krates.io'; return this.meta(n.type).label; }
collTreeByType(members){ const order=['deployment','statefulset','daemonset','service','ingress','crd','pod','configmap','secret','pvc']; const groups=[];
order.forEach(t=>{ const list=members.filter(n=>n.type===t); if(list.length) groups.push({type:t, label:this.meta(t).label+'s', nodes:list}); });
const seen=new Set(order); members.forEach(n=>{ if(!seen.has(n.type)){ seen.add(n.type); groups.push({type:n.type, label:this.meta(n.type).label+'s', nodes:members.filter(x=>x.type===n.type)}); } });
return groups; }
// k9s-xray style relationship tree: each object expands to the resources it references
xrayRel(a,b){ if(b.type==='pod') return 'pod'; if(b.type==='configmap') return 'configMap'; if(b.type==='secret') return 'secret'; if(b.type==='pvc') return 'volume'; if(a.type==='service') return 'selects'; if(a.type==='ingress') return 'routes'; if(a.type==='crd') return 'targets'; return 'ref'; }
buildXray(scope, members){ const isPrim=(n)=>['deployment','statefulset','daemonset','service','ingress','crd'].includes(n.type); const isCfg=(n)=>['configmap','secret','pvc'].includes(n.type);
const neigh=(id)=>(this.adj[id]||[]).map(x=>this.byId[x]); const workloadOf=(pod)=> neigh(pod.id).find(x=>isPrim(x)); const podsOf=(p)=> neigh(p.id).filter(x=>x.type==='pod'); const cfgOf=(p)=> neigh(p.id).filter(isCfg); const rows=[];
const push=(n,depth,rel)=>rows.push({node:n, depth:depth, rel:rel||''});
const expandWorkload=(p,d)=>{ push(p,d,''); podsOf(p).forEach(pod=>{ push(pod,d+1,'pod'); cfgOf(p).forEach(c=>push(c,d+2,this.xrayRel(p,c))); }); if(podsOf(p).length===0) cfgOf(p).forEach(c=>push(c,d+1,this.xrayRel(p,c))); };
if(scope.kind==='namespace'){ members.filter(isPrim).forEach(p=>{ if(p.type==='service'||p.type==='ingress'){ push(p,0,''); neigh(p.id).filter(isPrim).forEach(t=>push(t,1,this.xrayRel(p,t))); } else expandWorkload(p,0); }); }
else { const t=scope.value;
if(t==='pod'){ members.forEach(pod=>{ push(pod,0,''); const w=workloadOf(pod); if(w) cfgOf(w).forEach(c=>push(c,1,this.xrayRel(w,c))); }); }
else if(isPrim({type:t})){ members.forEach(p=>{ if(t==='service'||t==='ingress'){ push(p,0,''); neigh(p.id).filter(isPrim).forEach(tg=>push(tg,1,this.xrayRel(p,tg))); } else expandWorkload(p,0); }); }
else if(isCfg({type:t})){ members.forEach(c=>{ push(c,0,''); neigh(c.id).filter(isPrim).forEach(w=>{ push(w,1,'used by'); podsOf(w).forEach(pod=>push(pod,2,'pod')); }); }); }
else { members.forEach(n=>push(n,0,'')); } }
return rows; }
buildResults(query,filter){ const q=query.trim(); const items=[];
if(!filter){ this.state.krates.forEach(k=>{ let s; if(!q){ s=72; } else { const f=Math.max(this.fuzzy(q,k.label), this.fuzzy(q,'krate '+k.label)); if(f<0) return; s=f*10+66; } items.push({kind:'krate', id:k.id, krate:k, score:s}); }); }
if(filter){ const mem=this.catMembers(filter); items.push({kind:'category', id:'cat:'+filter, scope:{kind:'category',value:filter}, count:mem.length, score:1000}); }
if(!filter){ this.world.namespaces.forEach(ns=>{ const f=q? this.fuzzy(q, 'namespace '+ns.name): 30; const fn=q?this.fuzzy(q,ns.name):30; const sc=Math.max(f,fn); if(q && sc<0) return; items.push({kind:'namespace', id:'ns:'+ns.name, scope:{kind:'namespace',value:ns.name}, count:this.nsMembers(ns.name).length, color:ns.color, score:(q?sc*10+58:58)}); });
[['pod','pods'],['service','services'],['deployment','deployments'],['secret','secrets'],['statefulset','statefulsets'],['configmap','configmaps'],['ingress','ingresses'],['daemonset','daemonsets'],['pvc','pvcs'],['crd','crds']].forEach(([t,word])=>{ if(!q) return; const f=Math.max(this.fuzzy(q,'all '+word), this.fuzzy(q,word)); if(f<0) return; items.push({kind:'category', id:'cat:'+t, scope:{kind:'category',value:t}, count:this.catMembers(t).length, score:f*10+60}); }); }
let pool=this.world.nodes.slice(); if(filter) pool=pool.filter(n=>n.type===filter);
if(!q){ pool.forEach(n=>items.push({kind:'node', id:n.id, node:n, type:n.type, score:this.boost(n.type)})); }
else { pool.forEach(n=>{ const f=this.fuzzy(q,n.name); if(f>=0) items.push({kind:'node', id:n.id, node:n, type:n.type, score:f*10+this.boost(n.type)}); }); }
items.sort((a,b)=> b.score-a.score); return items.slice(0,8); }
// ---------- camera ----------
centerOn(wx,wy,z,fly){ const el=this.rootEl; const W=el?el.clientWidth:1280,H=el?el.clientHeight:760; const zoom=z||this.state.zoom;
this.setState({zoom, camX:W/2-wx*zoom, camY:H/2-wy*zoom, flying:!!fly, collapsed: zoom<0.48? this.state.collapsed : false});
if(fly){ clearTimeout(this._ft); this._ft=setTimeout(()=>this.setState({flying:false}),520); } }
onWheel=(e)=>{ e.preventDefault(); const el=this.rootEl; const rect=el.getBoundingClientRect(); const mx=e.clientX-rect.left,my=e.clientY-rect.top;
if(e.ctrlKey||e.metaKey){ const z0=this.state.zoom; let z1=z0*(1-e.deltaY*0.0042); z1=Math.max(.16,Math.min(2.2,z1));
const wx=(mx-this.state.camX)/z0, wy=(my-this.state.camY)/z0; let collapsed=this.state.collapsed;
if(z1<0.4) collapsed=true; else if(z1>0.5) collapsed=false;
this.setState({zoom:z1, camX:mx-wx*z1, camY:my-wy*z1, flying:false, collapsed}); return; }
this.setState(s=>({camX:s.camX - e.deltaX, camY:s.camY - e.deltaY, flying:false})); };
beginPan(e, canToggle){ const sx=e.clientX,sy=e.clientY,c0x=this.state.camX,c0y=this.state.camY; let moved=false;
const move=(ev)=>{ const dx=ev.clientX-sx,dy=ev.clientY-sy; if(Math.abs(dx)+Math.abs(dy)>4){ if(!moved){moved=true;this.setState({dragging:true});} this.setState({camX:c0x+dx,camY:c0y+dy,flying:false}); } };
const up=()=>{ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); if(this.state.dragging) this.setState({dragging:false});
if(!moved && canToggle && !this._space){ if(!this.state.spotOpen) this.openSpot(''); else this.setState({spotOpen:false,session:null}); } };
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); }
onRootDownCapture=(e)=>{ if(this._space && e.button===0){ e.preventDefault(); e.stopPropagation(); if(this.state.relMenu) this.setState({relMenu:null}); this.beginPan(e, false); } };
onBgDown=(e)=>{ if(e.button!==0) return; if(this.state.relMenu){ this.setState({relMenu:null}); return; } this.beginPan(e, true); };
// ---------- spotlight ----------
handleGlobalKey(e){ const tag=(e.target&&e.target.tagName)||''; if(tag==='INPUT'||tag==='TEXTAREA') return;
const shEl=this._hoverShWid && this._shInputEls[this._hoverShWid];
if(shEl && !this.state.spotOpen && !e.metaKey && !e.ctrlKey && !e.altKey){ if(e.key!==' '){ if(e.key.length===1){ e.preventDefault(); shEl.focus(); shEl.value+=e.key; return; } if(e.key==='Backspace'){ shEl.focus(); return; } if(e.key==='Enter'){ return; } } }
const ciEl=this._hoverCollWid && this._collInputEls[this._hoverCollWid];
if(ciEl && !this.state.spotOpen && !e.metaKey && !e.ctrlKey){ if(e.key!==' '){ if(this.collKey(this._hoverCollWid, e)){ if(!e.altKey) ciEl.focus(); return; } } }
if(this._hoverWinId && !this._hoverShWid && !this._hoverCollWid && !this.state.spotOpen && !e.metaKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase()==='z'){ e.preventDefault(); this.zoomToggleWindow(this._hoverWinId.kid, this._hoverWinId.wid); return; }
if(e.key===' '){ this._space=true; if(!this.state.spacePan) this.setState({spacePan:true}); if(!this.state.spotOpen) e.preventDefault(); return; }
if(this.state.spotOpen) return;
if(e.metaKey||e.ctrlKey||e.altKey) return; if(e.key==='Escape') return;
if(e.key==='/'){ e.preventDefault(); this.openSpot(''); } else if(e.key.length===1){ this.openSpot(e.key); } }
openSpot(initial){ this._focus=true; this._sessionKid=null; this.clearTimers(); this.setState({spotOpen:true, query:initial, sel:0, filterType:null, navigated:false, session:null}); this.armSoon(); }
filterTypeOrder(){ const all=['deployment','service','pod','secret','configmap','statefulset','daemonset','ingress','crd','pvc'];
const rec=this._recentTypes||[], use=this._typeUse||{};
return all.slice().sort((a,b)=>{ const ra=rec.indexOf(a), rb=rec.indexOf(b); const va=ra<0?999:ra, vb=rb<0?999:rb; if(va!==vb) return va-vb; return (use[b]||0)-(use[a]||0); }); }
recordType(t){ if(!t) return; this._typeUse=this._typeUse||{}; this._typeUse[t]=(this._typeUse[t]||0)+1; this._recentTypes=[t].concat((this._recentTypes||[]).filter(x=>x!==t)).slice(0,10); }
cycleFilter(dir){ const seq=[null].concat(this.filterTypeOrder()); const cur=this.state.filterType; let i=seq.indexOf(cur); if(i<0) i=0; const ni=(i+dir+seq.length)%seq.length; const nt=seq[ni]; this._focus=true; this.setState({filterType:nt, sel:0, navigated:false}); }
onSearchChange=(e)=>{ this.setState({query:e.target.value, sel:0, navigated:false}); this.armSoon(); };
onSearchKeydown=(e)=>{ const res=this.buildResults(this.state.query,this.state.filterType);
if(e.key==='Escape'){ e.preventDefault(); this.finishOrClose(); return; }
if(this.state.session) this.armDismiss();
if(e.key==='ArrowDown'){ e.preventDefault(); this.setState(s=>({sel:Math.min(res.length-1,s.sel+1),navigated:true})); return; }
if(e.key==='ArrowUp'){ e.preventDefault(); this.setState(s=>({sel:Math.max(0,s.sel-1),navigated:true})); return; }
if(e.key==='Tab'){ e.preventDefault(); const sug=this.suggestType(this.state.query,this.state.filterType);
if(sug && !this.state.filterType && !e.shiftKey){ this.recordType(sug); this._focus=true; this.setState({filterType:sug, query:'', sel:0, navigated:false}); }
else { this.cycleFilter(e.shiftKey?-1:1); } return; }
if(e.key==='Backspace' && this.state.query==='' && this.state.filterType){ e.preventDefault(); this.setState({filterType:null,sel:0}); return; }
if(e.key==='Enter'){ e.preventDefault(); if(this.state.session){ this.finishSpotlight(); } else { const r=res[this.state.sel]; if(r){ if(r.kind==='krate'){ this.jumpToKrate(r.id); } else if(r.kind==='category'||r.kind==='namespace'){ this.summonCollection(r.scope); } else { this.openView(r.id,this.defaultView(r.type),true); } } } return; }
if(e.altKey && !e.ctrlKey && !e.metaKey){ const v=this.keyToView(this.codeLetter(e)); if(v){ const r=res[this.state.sel]; if(r && r.kind==='node' && this.viewAvail(r.type,v)){ e.preventDefault(); this.openView(r.id,v,false); return; } } } };
onSpotBackdrop=()=>this.finishOrClose();
finishOrClose(){ this.clearTimers(); if(this.state.session) this.finishSpotlight(); else this.setState({spotOpen:false}); }
// ---------- krates / windows ----------
placeKrate(){ const ks=this.state.krates; if(!ks.length) return {cx:700, cy:480};
const last=ks[ks.length-1]; const lb=this.krateBox(last); return {cx:last.wx+lb.x+lb.w+360, cy:last.wy}; }
gridCols(n){ return n<=1?1:(n<=4?2:3); }
tileWindows(wins){ const gap=14; const N=wins.length; if(!N) return wins; const C=this.gridCols(N); const rows=Math.ceil(N/C);
const colW=[], rowH=[]; wins.forEach((w,i)=>{ const c=i%C, r=Math.floor(i/C); colW[c]=Math.max(colW[c]||300, w.w); rowH[r]=Math.max(rowH[r]||220, w.h); });
const colX=[]; let ax=0; for(let c=0;c<C;c++){ colX[c]=ax; ax+=colW[c]+gap; }
const rowY=[]; let ay=0; for(let r=0;r<rows;r++){ rowY[r]=ay; ay+=rowH[r]+gap; }
return wins.map((w,i)=>{ const c=i%C, r=Math.floor(i/C); return Object.assign({},w,{dx:colX[c], dy:rowY[r], w:colW[c], h:rowH[r], col:c, row:r}); }); }
fitBox(x,y,w,h,maxZ){ const el=this.rootEl; const W=el?el.clientWidth:1280,H=el?el.clientHeight:760; const pad=190; let z=Math.min((W-pad)/w,(H-pad)/h, maxZ||1.0); z=Math.max(0.22,Math.min(2.0,z)); this.centerOn(x+w/2, y+h/2, z, true); }
fitKrate(kid){ const k=this.state.krates.find(x=>x.id===kid); if(!k) return; const b=this.krateBox(k); this.fitBox(k.wx+b.x, k.wy+b.y-44, b.w, b.h+44, 1.0); }
krateBox(k){ if(!k.windows.length) return {x:0,y:0,w:380,h:360};
let a=1e9,b=1e9,c=-1e9,d=-1e9; k.windows.forEach(w=>{ a=Math.min(a,w.dx); b=Math.min(b,w.dy); c=Math.max(c,w.dx+w.w); d=Math.max(d,w.dy+w.h); });
return {x:a,y:b,w:c-a,h:d-b}; }
openView(objId, view, finish){
const s=this.state; let session = (s.session && s.session.objId===objId) ? s.session : null;
let krates=s.krates.map(k=>Object.assign({},k,{windows:k.windows.slice()})); let krateSeq=s.krateSeq;
let krate = session ? krates.find(k=>k.id===session.kid) : null;
if(!krate){ const p=this.placeKrate(); const n=this.byId[objId];
krate={id:'k'+krateSeq, objId, label:n.name, status:n.status, color:(this.world.namespaces.find(x=>x.name===n.ns)||{}).color||'#6fb1ff', wx:p.cx, wy:p.cy, windows:[], seq:0};
krateSeq++; krates.push(krate); session={kid:krate.id, objId, views:[]}; }
if(!krate.windows.find(w=>(w.objId||krate.objId)===objId && w.tab===view)){ let wl=krate.windows.slice();
wl.push({wid:krate.id+'-'+(krate.seq++), objId:objId, tab:view, dx:0, dy:0, w:380, h:360}); krate.windows=this.tileWindows(wl); }
session=Object.assign({},session,{views:Array.from(new Set(session.views.concat([view])))});
this._sessionKid=krate.id;
this.setState({krates, krateSeq, session}, ()=>{ if(finish){ this.finishSpotlight(); } else { this.armDismiss(); } });
}
finishSpotlight(){ this.clearTimers(); const kid=this._sessionKid; this.setState({spotOpen:false, session:null, navigated:false}, ()=>this.fitKrate(kid)); this._sessionKid=null; }
flyToKrate(kid){ this.fitKrate(kid); }
jumpToKrate(kid){ this.clearTimers(); this.setState({spotOpen:false, session:null, navigated:false}, ()=>this.fitKrate(kid)); }
cardLayout(){ const el=this.rootEl; const W=el?el.clientWidth:1280; const cardW=230, cardH=132, gap=20; const cols=Math.max(1, Math.floor((W-72+gap)/(cardW+gap)));
const n=this.state.krates.length; const span=Math.min(n||1,cols); const contentW=span*cardW+(span-1)*gap; const startX=Math.max(36,(W-contentW)/2); const startY=96; return {cardW,cardH,gap,cols,startX,startY}; }
cardSlotAt(x,y){ const L=this.cardLayout(); const col=Math.round((x-L.startX)/(L.cardW+L.gap)); const row=Math.round((y-L.startY)/(L.cardH+L.gap)); const cc=Math.max(0,Math.min(L.cols-1,col)); const idx=row*L.cols+cc; return Math.max(0,Math.min(this.state.krates.length-1,idx)); }
reorderKrate(kid,ti){ this.setState(s=>{ const arr=s.krates.slice(); const from=arr.findIndex(k=>k.id===kid); if(from<0) return null; ti=Math.max(0,Math.min(arr.length-1,ti)); if(from===ti) return null; const it=arr.splice(from,1)[0]; arr.splice(ti,0,it); return {krates:arr}; }); }
startCardDrag(kid,e){ e.stopPropagation(); if(e.button!==0) return; const sx=e.clientX,sy=e.clientY; let moved=false;
const move=(ev)=>{ const dx=ev.clientX-sx,dy=ev.clientY-sy; if(!moved && Math.abs(dx)+Math.abs(dy)>5) moved=true; if(moved){ const ti=this.cardSlotAt(ev.clientX,ev.clientY); this.reorderKrate(kid,ti); this.setState({cardDrag:{id:kid, mx:ev.clientX, my:ev.clientY}}); } };
const up=()=>{ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); this.setState({cardDrag:null}); };
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); }
focusWindow(kid,wid){ const k=this.state.krates.find(x=>x.id===kid); const w=k&&k.windows.find(x=>x.wid===wid); if(!w) return; this.fitBox(k.wx+w.dx, k.wy+w.dy-44, w.w, w.h+44, 1.35); }
closeWindow(kid,wid){ this.setState(s=>{ let krates=s.krates.map(k=>{ if(k.id!==kid) return k; return Object.assign({},k,{windows:this.tileWindows(k.windows.filter(w=>w.wid!==wid))}); }); krates=krates.filter(k=>k.windows.length>0); return {krates}; }); }
startWinResize(kid,wid,e){ e.stopPropagation(); const sx=e.clientX,sy=e.clientY; const k=this.state.krates.find(x=>x.id===kid); const w=k&&k.windows.find(x=>x.wid===wid); if(!w) return; const w0=w.w,h0=w.h,z=this.state.zoom;
const move=(ev)=>{ const nw=Math.max(300, w0+(ev.clientX-sx)/z), nh=Math.max(220, h0+(ev.clientY-sy)/z); this.setState(s=>({krates:s.krates.map(kk=>kk.id!==kid?kk:Object.assign({},kk,{windows:this.tileWindows(kk.windows.map(ww=>ww.wid===wid?Object.assign({},ww,{w:nw,h:nh}):ww))}))})); };
const up=()=>{ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); };
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); }
closeKrate(kid){ this.setState(s=>({krates:s.krates.filter(k=>k.id!==kid)})); }
setTab(kid,wid,tab){ this.setState(s=>({krates:s.krates.map(k=>k.id!==kid?k:Object.assign({},k,{windows:k.windows.map(w=>w.wid===wid?Object.assign({},w,{tab}):w)}))})); }
startWinDrag(kid,wid,e){ e.stopPropagation(); const sx=e.clientX,sy=e.clientY; const k=this.state.krates.find(x=>x.id===kid); const w=k&&k.windows.find(x=>x.wid===wid); if(!w) return; const x0=w.dx,y0=w.dy,z=this.state.zoom;
const move=(ev)=>{ const ndx=x0+(ev.clientX-sx)/z, ndy=y0+(ev.clientY-sy)/z; this.setState(s=>({krates:s.krates.map(kk=>kk.id!==kid?kk:Object.assign({},kk,{windows:kk.windows.map(ww=>ww.wid===wid?Object.assign({},ww,{dx:ndx,dy:ndy}):ww)}))})); };
const up=()=>{ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); };
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); }
startKrateDrag(kid,e){ e.stopPropagation(); const sx=e.clientX,sy=e.clientY; const k=this.state.krates.find(x=>x.id===kid); if(!k) return; const x0=k.wx,y0=k.wy,z=this.state.zoom;
const move=(ev)=>{ const nx=x0+(ev.clientX-sx)/z, ny=y0+(ev.clientY-sy)/z; this.setState(s=>({krates:s.krates.map(kk=>kk.id!==kid?kk:Object.assign({},kk,{wx:nx,wy:ny}))})); };
const up=()=>{ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); this.resolveKrateOverlaps(kid); };
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); }
krateFootprint(k){ if(k.collapsed){ return {x:k.wx, y:k.wy-44, w:300, h:104}; } const b=this.krateBox(k); return {x:k.wx+b.x-30, y:k.wy+b.y-72, w:b.w+60, h:b.h+102}; }
resolveKrateOverlaps(activeKid){ this.setState(s=>{ const ks=s.krates.map(k=>Object.assign({},k)); const exp=(k)=>{ const f=this.krateFootprint(k); return {x:f.x-14,y:f.y-14,w:f.w+28,h:f.h+28}; };
for(let it=0; it<80; it++){ let moved=false;
for(let i=0;i<ks.length;i++) for(let j=i+1;j<ks.length;j++){ const A=exp(ks[i]), B=exp(ks[j]);
const ox=Math.min(A.x+A.w,B.x+B.w)-Math.max(A.x,B.x); const oy=Math.min(A.y+A.h,B.y+B.h)-Math.max(A.y,B.y);
if(ox>0 && oy>0){ moved=true; if(ox<oy){ const dir=((A.x+A.w/2)<=(B.x+B.w/2))?1:-1; if(ks[i].id===activeKid){ ks[j].wx+=dir*ox; } else if(ks[j].id===activeKid){ ks[i].wx-=dir*ox; } else { ks[i].wx-=dir*ox/2; ks[j].wx+=dir*ox/2; } }
else { const dir=((A.y+A.h/2)<=(B.y+B.h/2))?1:-1; if(ks[i].id===activeKid){ ks[j].wy+=dir*oy; } else if(ks[j].id===activeKid){ ks[i].wy-=dir*oy; } else { ks[i].wy-=dir*oy/2; ks[j].wy+=dir*oy/2; } } } }
if(!moved) break; }
ks.forEach(k=>{ k.wx=Math.round(k.wx/20)*20; k.wy=Math.round(k.wy/20)*20; });
return {krates:ks}; }); }
toggleCollapseKrate(kid){ this.setState(s=>({krates:s.krates.map(k=>k.id===kid?Object.assign({},k,{collapsed:!k.collapsed}):k), zoomedWin:null}), ()=>this.resolveKrateOverlaps(kid)); }
zoomToggleWindow(kid,wid){ const k=this.state.krates.find(x=>x.id===kid); const w=k&&k.windows.find(x=>x.wid===wid); if(!w) return;
if(this.state.zoomedWin===wid){ const ps=this._prevSize&&this._prevSize[wid];
this.setState(s=>({zoomedWin:null, krates:s.krates.map(kk=>kk.id!==kid?kk:Object.assign({},kk,{windows:this.tileWindows(kk.windows.map(ww=>(ww.wid===wid&&ps)?Object.assign({},ww,{w:ps.w,h:ps.h}):ww))}))}), ()=>this.fitKrate(kid)); }
else { const el=this.rootEl; const W=el?el.clientWidth:1280,H=el?el.clientHeight:760; const sideM=22, topInset=68, botInset=66; const nw=Math.max(360,W-2*sideM), nh=Math.max(260,H-topInset-botInset);
this._prevSize=this._prevSize||{}; this._prevSize[wid]={w:w.w,h:w.h};
this.setState(s=>({zoomedWin:wid, krates:s.krates.map(kk=>kk.id!==kid?kk:Object.assign({},kk,{windows:kk.windows.map(ww=>ww.wid===wid?Object.assign({},ww,{w:nw,h:nh}):ww)}))}), ()=>{ const k2=this.state.krates.find(x=>x.id===kid); const w2=k2&&k2.windows.find(x=>x.wid===wid); if(w2){ const camX=sideM-(k2.wx+w2.dx), camY=topInset-(k2.wy+w2.dy); this.setState({zoom:1, camX, camY, flying:true}); clearTimeout(this._ft); this._ft=setTimeout(()=>this.setState({flying:false}),520); } }); } }
mmJump(e){ const r=this._mmEl?this._mmEl.getBoundingClientRect():null; if(!r||!this._mm) return; const mx=e.clientX-r.left, my=e.clientY-r.top; const wx=this._mm.bx+mx/this._mm.scale, wy=this._mm.by+my/this._mm.scale; this.centerOn(wx,wy,this.state.zoom,true); }
nodeYaml(n){ const d=n.data||{};
if(n.type==='secret'){ let s='apiVersion: v1\nkind: Secret\nmetadata:\n name: '+n.name+'\n namespace: '+n.ns+'\ntype: Opaque\ndata: # decoded\n'; const sec=d.secret||{}; Object.keys(sec).forEach(k=>{ s+=' '+k+': '+sec[k]+'\n'; }); return s; }
if(n.type==='configmap'){ return 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: '+n.name+'\n namespace: '+n.ns+'\ndata:\n LOG_LEVEL: info\n TIMEOUT: "30s"\n REGION: eu-west-1'; }
if(n.type==='pvc'){ return 'apiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: '+n.name+'\n namespace: '+n.ns+'\nspec:\n accessModes: [ReadWriteOnce]\n resources:\n requests:\n storage: 20Gi\n storageClassName: gp3'; }
if(n.type==='pod'){ return 'apiVersion: v1\nkind: Pod\nmetadata:\n name: '+n.name+'\n namespace: '+n.ns+'\nspec:\n containers:\n - name: app\n image: '+(d.img||'krates/app:latest')+'\nstatus:\n phase: '+n.status; }
if(n.type==='service'){ return 'apiVersion: v1\nkind: Service\nmetadata:\n name: '+n.name+'\n namespace: '+n.ns+'\nspec:\n type: ClusterIP\n selector:\n app: '+(d.relTo||n.name)+'\n ports:\n - port: '+((d.port||'80:80').split(':')[0])+'\n targetPort: '+((d.port||'80:80').split(':')[1]); }
if(n.type==='ingress'){ return 'apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n name: '+n.name+'\n namespace: '+n.ns+'\nspec:\n rules:\n - host: '+n.ns+'.krates.io\n http:\n paths:\n - path: /\n backend:\n service:\n name: '+(d.relTo||'app'); }
if(n.type==='crd'){ return 'apiVersion: krates.io/v1\nkind: '+(d.kindName||'CustomResource')+'\nmetadata:\n name: '+n.name.split('/').pop()+'\n namespace: '+n.ns+'\nspec:\n target: '+(d.relTo||'')+'\nstatus:\n phase: Active'; }
const kind=n.type==='statefulset'?'StatefulSet':(n.type==='daemonset'?'DaemonSet':'Deployment');
return 'apiVersion: apps/v1\nkind: '+kind+'\nmetadata:\n name: '+n.name+'\n namespace: '+n.ns+'\nspec:\n replicas: '+((d.replicas||'1/1').split('/')[1])+'\n template:\n spec:\n containers:\n - name: '+n.name+'\n image: '+(d.img||'krates/app:latest')+'\nstatus:\n readyReplicas: '+((d.replicas||'1/1').split('/')[0]); }
nodeDescribe(n){ const m=this.meta(n.type); const d=n.data||{};
let s='Name: '+n.name+'\nNamespace: '+n.ns+'\nKind: '+m.label+'\nStatus: '+n.status+'\n';
if(d.img) s+='Image: '+d.img+'\n'; if(d.replicas) s+='Replicas: '+d.replicas+' ready\n'; if(d.port) s+='Port: '+d.port+'\n';
s+='Node: ip-10-0-'+(3+(n.name.length%6))+'-'+(12+(n.name.length%40))+'\nAge: '+(2+(n.name.length%18))+'d\nLabels: app='+n.name.split(/[-/]/)[0]+'\n';
const nb=(this.adj[n.id]||[]).map(id=>this.byId[id]).filter(Boolean); s+='\nRelated ('+nb.length+'):\n'+nb.map(x=>' → '+this.meta(x.type).short+'/'+x.name).join('\n'); return s; }
nodeLogs(n){ if(!this.execable(n.type)) return 'No logs for '+this.meta(n.type).label+' objects.';
const base=n.name.split(/[-/]/)[0]; const t=(i)=>'09:'+(12+i)+':'+((10+i*7)%60<10?'0':'')+((10+i*7)%60);
return [t(0)+' INFO '+base+' starting, build '+(n.data&&n.data.img||'dev'),t(1)+' INFO listening on :8080',t(2)+' INFO connected to upstream postgres.payments',t(3)+' WARN retry budget at 82%',t(4)+' INFO GET /healthz 200 1ms',t(5)+' INFO reconciled 14 routes',t(6)+' '+(n.status==='Degraded'?'ERROR liveness probe failed (3/3)':'INFO request 7f3a 200 23ms'),t(7)+' INFO GET /v1/orders 200 14ms'].join('\n'); }
nodeShell(n){ if(!this.execable(n.type)) return 'Exec not available for '+this.meta(n.type).label+'.';
return '$ kubectl exec -it '+n.name+' -n '+n.ns+' -- sh\n/ # whoami\nroot\n/ # ls /app\nbin config data server\n/ # cat /app/config/region\neu-west-1\n/ # \u2588'; }
winBody(n,tab){ if(tab==='describe') return this.nodeDescribe(n); if(tab==='yaml') return this.nodeYaml(n); return ''; }
// ---------- live logs ----------
seedLogs(n){ const base=n.name.split(/[-/]/)[0]; const T=(i)=>'09:'+(40+Math.floor(i/60))+':'+((i%60)<10?'0':'')+(i%60); let i=0;
return [T(i++)+' INFO '+base+' starting, build '+((n.data&&n.data.img)||'dev'), T(i+=7)+' INFO config loaded from /app/config', T(i+=4)+' INFO listening on :8080', T(i+=6)+' INFO connected to postgres.payments', T(i+=5)+' INFO GET /healthz 200 1ms', T(i+=9)+' INFO reconciled 14 routes', T(i+=3)+(n.status==='Degraded'?' ERROR liveness probe failed (3/3)':' INFO GET /v1/orders 200 14ms')]; }
logLine(n){ const base=n.name.split(/[-/]/)[0]; const now=new Date(); const ts=('0'+now.getHours()).slice(-2)+':'+('0'+now.getMinutes()).slice(-2)+':'+('0'+now.getSeconds()).slice(-2);
const paths=['/healthz','/v1/orders','/v1/cart','/metrics','/readyz','/v1/checkout']; const codes=['200','200','200','204','200','503','200','404'];
const c=codes[Math.floor(Math.random()*codes.length)]; const p=paths[Math.floor(Math.random()*paths.length)]; const ms=(1+Math.floor(Math.random()*60));
if(c==='503') return ts+' ERROR '+base+' upstream timeout on '+p+' (retry 2/3)';
if(c==='404') return ts+' WARN '+base+' '+p+' 404 not found';
return ts+' INFO '+base+' GET '+p+' '+c+' '+ms+'ms'; }
logColor(t){ return /ERROR/.test(t)?'#ef6f6f':(/WARN/.test(t)?'#e8b54a':'#aab8cc'); }
appendLogs(){ const S=this.state; if(!S.krates.length) return; let changed=false; const logs=Object.assign({},S.logs);
S.krates.forEach(k=>{ k.windows.forEach(w=>{ const n=this.byId[w.objId||k.objId]; if(w.tab!=='logs'||!this.execable(n.type)) return; const tk=w.wid+'#logs';
const buf=(logs[tk]?logs[tk].slice():this.seedLogs(n)); buf.push(this.logLine(n)); if(buf.length>160) buf.splice(0,buf.length-160); logs[tk]=buf; changed=true; }); });
if(changed) this.setState({logs}); }
// ---------- interactive shell ----------
seedShell(n){ return {cwd:'/', lines:[{t:'out',text:'krates shell · '+n.name+' · ns/'+n.ns},{t:'out',text:"connected. type 'help' for commands."}]}; }
prompt(n,cwd){ return 'root@'+n.name+':'+cwd+'#'; }
shellEval(n,sess,cmd){ const c=cmd.trim(); if(!c) return {out:[]}; const parts=c.split(/\s+/); const a=parts[0];
const fs={'/':['app','bin','etc','proc','var'],'/app':['bin','config','data','server','VERSION'],'/app/config':['region','log_level','feature_flags']};
if(a==='help') return {out:['available: ls cd pwd cat env ps whoami hostname date kubectl echo clear exit']};
if(a==='whoami') return {out:['root']};
if(a==='hostname') return {out:[n.name]};
if(a==='date') return {out:[new Date().toUTCString()]};
if(a==='pwd') return {out:[sess.cwd]};
if(a==='ls') return {out:[(fs[parts[1]||sess.cwd]||fs[sess.cwd]||[]).join(' ')]};
if(a==='env') return {out:['POD_NAME='+n.name,'NAMESPACE='+n.ns,'REGION=eu-west-1','LOG_LEVEL=info','PORT=8080','IMAGE='+((n.data&&n.data.img)||'krates/app')]};
if(a==='ps') return {out:[' PID USER COMMAND',' 1 root /app/server',' 18 root sh',' 42 root ps']};
if(a==='cat'){ const f=(parts[1]||''); if(/region/.test(f)) return {out:['eu-west-1']}; if(/VERSION/.test(f)) return {out:[((n.data&&n.data.img)||'1.0.0')]}; if(/log_level/.test(f)) return {out:['info']}; if(/feature_flags/.test(f)) return {out:['checkout_v2=true','dark_mode=false']}; return {out:['cat: '+f+': No such file or directory']}; }
if(a==='cd'){ let t=parts[1]||'/'; if(t==='..'){ const s=sess.cwd.split('/').filter(Boolean); s.pop(); t='/'+s.join('/'); } else if(t!=='/' && !t.startsWith('/')){ t=(sess.cwd==='/'?'':sess.cwd)+'/'+t; } t=t.replace(/\/+/g,'/'); if(t!=='/'&&t.length>1)t=t.replace(/\/$/,''); if(fs[t]||t==='/') return {out:[],cwd:t}; return {out:['cd: '+parts[1]+': Not a directory']}; }
if(a==='kubectl') return {out:['tip: explore visually in krates — or pipe real kubectl here']};
if(a==='echo') return {out:[parts.slice(1).join(' ')]};
if(a==='exit') return {out:['use × to close the window']};
return {out:[a+": command not found (try 'help')"]}; }
runShell(wid,n,cmd){ const tk=wid+'#shell'; const sh=Object.assign({},this.state.sh); const prev=sh[tk]||this.seedShell(n); const sess={cwd:prev.cwd,lines:prev.lines.slice()};
if(cmd.trim()==='clear'){ sess.lines=[]; sh[tk]=sess; this._stickSh[wid]=true; this.setState({sh}); return; }
sess.lines.push({t:'in',text:this.prompt(n,sess.cwd)+' '+cmd}); const r=this.shellEval(n,sess,cmd); (r.out||[]).forEach(o=>sess.lines.push({t:'out',text:o})); if(r.cwd!==undefined) sess.cwd=r.cwd;
if(sess.lines.length>200) sess.lines.splice(0,sess.lines.length-200); sh[tk]=sess; this._stickSh[wid]=true; this.setState({sh}); }
shellKey(kid,wid,n,e){ if(e.key!=='Enter') return; e.preventDefault(); e.stopPropagation(); const el=e.target; const cmd=el.value; el.value=''; this.runShell(wid,n,cmd); }
// ---------- open related ----------
openRelated(kid,relId){ const view=this.defaultView(this.byId[relId].type);
this.setState(s=>{ const krates=s.krates.map(k=>{ if(k.id!==kid) return k; let wl=k.windows.slice(); if(!wl.find(w=>(w.objId||k.objId)===relId && w.tab===view)){ wl=wl.concat([{wid:k.id+'-'+k.seq, objId:relId, tab:view, dx:0,dy:0,w:380,h:360}]); } return Object.assign({},k,{windows:this.tileWindows(wl), seq:k.seq+1}); }); return {krates, relMenu:null}; }, ()=>this.fitKrate(kid)); }
openInKrate(kid,objId,view){ this.setState(s=>{ const krates=s.krates.map(k=>{ if(k.id!==kid) return k; let wl=k.windows.slice(); if(!wl.find(w=>w.kind!=='collection' && (w.objId||k.objId)===objId && w.tab===view)){ wl=wl.concat([{wid:k.id+'-'+k.seq, objId, tab:view, dx:0,dy:0,w:380,h:360}]); } return Object.assign({},k,{windows:this.tileWindows(wl), seq:k.seq+1}); }); return {krates}; }, ()=>this.fitKrate(kid)); }
// ---------- collections ----------
summonCollection(scope){ this.clearTimers(); if(scope.kind==='category') this.recordType(scope.value); const existing=this.state.krates.find(k=>k.collScope && k.collScope.kind===scope.kind && k.collScope.value===scope.value);
if(existing){ this._focusCollWid=existing.windows[0].wid; this.setState({spotOpen:false, session:null, navigated:false}, ()=>this.fitKrate(existing.id)); return; }
const p=this.placeKrate(); const color = scope.kind==='namespace'? ((this.world.namespaces.find(x=>x.name===scope.value)||{}).color||'#6fb1ff') : this.meta(scope.value).color;
const wid0='k'+this.state.krateSeq+'-0'; const k={ id:'k'+this.state.krateSeq, objId:null, collScope:scope, label:this.collTitle(scope), status:'Ready', color, wx:p.cx, wy:p.cy, seq:1,
windows:[{wid:wid0, kind:'collection', scope, dx:0, dy:0, w:540, h:480}] };
this._focusCollWid=wid0;
this.setState(s=>({krates:s.krates.concat([k]), krateSeq:s.krateSeq+1, spotOpen:false, session:null, navigated:false}), ()=>this.fitKrate(k.id)); }
setCollSearch(wid,val){ this._collNavTimer=this._collNavTimer||{}; clearTimeout(this._collNavTimer[wid]); this.setState(s=>({collSearch:Object.assign({},s.collSearch,{[wid]:val}), collSel:Object.assign({},s.collSel,{[wid]:0}), collNav:Object.assign({},s.collNav,{[wid]:false})})); }
armCollNav(wid){ this._collNavTimer=this._collNavTimer||{}; clearTimeout(this._collNavTimer[wid]); this.setState(s=> s.collNav[wid]?null:({collNav:Object.assign({},s.collNav,{[wid]:true})}) );
this._collNavTimer[wid]=setTimeout(()=>{ this.setState(s=>({collNav:Object.assign({},s.collNav,{[wid]:false})})); }, 500); }
cancelCollNav(wid){ this._collNavTimer=this._collNavTimer||{}; clearTimeout(this._collNavTimer[wid]); this.setState(s=>({collNav:Object.assign({},s.collNav,{[wid]:false})})); }
// unified collection key handling — works whether the filter is focused OR the mouse is just hovering the box
collKey(wid, e){ const ctx=this._collCtx[wid]; if(!ctx) return false; const kid=ctx.kid, nodes=ctx.nodes;
if(e.key==='ArrowDown'){ e.preventDefault(); this.setState(s=>({collSel:Object.assign({},s.collSel,{[wid]:Math.min(nodes.length-1,(s.collSel[wid]||0)+1)})})); return true; }
if(e.key==='ArrowUp'){ e.preventDefault(); this.setState(s=>({collSel:Object.assign({},s.collSel,{[wid]:Math.max(0,(s.collSel[wid]||0)-1)})})); return true; }
const sel=Math.min(this.state.collSel[wid]||0, nodes.length-1); const node=nodes[sel];
if(e.altKey && !e.ctrlKey && !e.metaKey){ const v=this.keyToView(this.codeLetter(e)); if(v && node && this.viewAvail(node.type,v)){ e.preventDefault(); this.openInKrate(kid, node.id, v); return true; } return false; }
if(e.key==='Enter'){ e.preventDefault(); if(node) this.openInKrate(kid, node.id, this.defaultView(node.type)); return true; }
if(e.key==='Escape'){ if(e.target&&e.target.blur)e.target.blur(); return true; }
if(e.key===' ') return false; // reserve for pan
if(e.key.length===1 && !e.metaKey && !e.ctrlKey){ e.preventDefault(); this.setCollSearch(wid, (this.state.collSearch[wid]||'')+e.key); return true; }
if(e.key==='Backspace'){ e.preventDefault(); this.setCollSearch(wid, (this.state.collSearch[wid]||'').slice(0,-1)); return true; }
return false; }
handleCollKey(e, kid, wid, nodes){ this.collKey(wid, e); }
toggleCollView(wid){ this.setState(s=>({collView:Object.assign({},s.collView,{[wid]: (s.collView[wid]==='tree'?'list':'tree')})})); }
toggleAdmin=()=>{ this.setState(s=>({admin:!s.admin})); };
adminData(){ return [
{name:'Dana Okafor', color:'#6fb1ff', status:'active · payments', statusColor:'#4ad07a', query:'ledger', typing:true, krates:[['deploy/ledger','L Y','Degraded'],['All Secrets','▤','Ready']]},
{name:'Wen Li', color:'#5fd0c0', status:'active · platform', statusColor:'#4ad07a', query:'auth oauth-secret', typing:false, krates:[['deploy/auth','D S','Ready'],['ns/platform','▤','Ready']]},
{name:'Ravi Patel', color:'#e58fb0', status:'idle 4m · storefront', statusColor:'#7e8aa2', query:'redis', typing:false, krates:[['sts/redis','L Y','Ready']]},
{name:'You', color:null, status:'active', statusColor:'#4ad07a', query:'—', typing:false, krates:[]} ]; }
// ---------- refs ----------
setRoot=(el)=>{ this.rootEl=el; };
setInput=(el)=>{ this.inputEl=el; };
stopDown=(e)=>{ e.stopPropagation(); };
winWheel=(e)=>{ if(e.ctrlKey||e.metaKey) return; e.stopPropagation(); };
renderVals(){
const S=this.state; const A=this.accentSet(); const VM=this.viewMeta();
const W=this.rootEl?this.rootEl.clientWidth:1280, H=this.rootEl?this.rootEl.clientHeight:760;
const toScreen=(wx,wy)=>({x:S.camX+wx*S.zoom, y:S.camY+wy*S.zoom});
const frames=[]; const wins=[]; const cards=[]; const minis=[];
if(!S.collapsed){
S.krates.forEach(k=>{
if(k.collapsed){ minis.push({ x:k.wx, y:k.wy-44, color:k.color, outline:this.rgba(k.color,.3), label:k.label, status:k.status, statusColor:this.statusColor(k.status), count:k.windows.length,
onExpand:()=>this.toggleCollapseKrate(k.id), onDrag:(e)=>this.startKrateDrag(k.id,e), onClose:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.closeKrate(k.id); } }); return; }
const box=this.krateBox(k); const n=this.byId[k.objId];
frames.push({left:k.wx+box.x-30, top:k.wy+box.y-30, w:box.w+60, h:box.h+60, border:this.rgba(k.color,.3), fill:this.rgba(k.color,.04),
labelLeft:k.wx+box.x-30, labelTop:k.wy+box.y-72, color:k.color, label:k.label, status:k.status, statusColor:this.statusColor(k.status), count:k.windows.length,
onCollapse:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.toggleCollapseKrate(k.id); }, onToggleCollapse:()=>this.toggleCollapseKrate(k.id),
onDrag:(e)=>this.startKrateDrag(k.id,e), onClose:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.closeKrate(k.id); } });
k.windows.forEach(w=>{ if(w.kind==='collection'){ const scope=w.scope; const allMem=this.collMembers(scope); const qv=(S.collSearch[w.wid]||''); const q=qv.trim();
const matches=(nn)=> !q || this.fuzzy(q,nn.name)>=0 || this.fuzzy(q,this.meta(nn.type).short)>=0 || this.fuzzy(q,nn.ns)>=0;
const summ=this.statusSummary(allMem); const mode=S.collView[w.wid]||'list'; const csel=S.collSel[w.wid]||0;
const rowOf=(nn,depth,rel,oi)=>{ const mm=this.meta(nn.type); const ssc=this.shapeCss(mm.shape); const av=this.viewOrder().filter(v=>this.viewAvail(nn.type,v));
return { id:nn.id+':'+oi, name:nn.name, row:true, isGroup:false, indent:depth||0, indentPx:(depth||0)*20, child:(depth||0)>0, rel:rel||'', hasRel:!!rel, typeShort:mm.short, color:mm.color, clip:ssc.clip, radius:ssc.radius, statusColor:this.statusColor(nn.status), status:nn.status, metric:this.healthMetric(nn),
selected:(oi===csel), selBg:(oi===csel?A.dim:'transparent'), selBar:(oi===csel?A.accent:'transparent'),
views:av.map(v=>({letter:VM[v].letter, onClick:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.openInKrate(k.id, nn.id, v); }})),
onOpen:()=>this.openInKrate(k.id, nn.id, this.defaultView(nn.type)),
onHover:()=>this.setState(s=>({collSel:Object.assign({},s.collSel,{[w.wid]:oi})})),
onRelated:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.setState({relMenu:{kid:k.id, objId:nn.id, sx:Math.min(e.clientX,W-244), sy:Math.min(e.clientY,H-260)}}); } }; };
let rows=[]; let visNodes=[];
if(mode==='tree'){ let xr=this.buildXray(scope, allMem);
if(q){ const keep=new Array(xr.length).fill(false); for(let i=0;i<xr.length;i++){ if(matches(xr[i].node)){ keep[i]=true; let d=xr[i].depth; for(let j=i-1;j>=0&&d>0;j--){ if(xr[j].depth<d){ keep[j]=true; d=xr[j].depth; } } } } xr=xr.filter((r,i)=>keep[i]); }
visNodes=xr.map(r=>r.node); rows=xr.map((r,oi)=>rowOf(r.node, r.depth, r.rel, oi)); }
else { const rank=(nn)=>(nn.status==='Degraded'||nn.status==='Failed')?0:(nn.status==='Pending'?1:2); const list=allMem.filter(matches).slice().sort((a,b)=>rank(a)-rank(b)||a.name.localeCompare(b.name)); visNodes=list; rows=list.map((nn,oi)=>rowOf(nn,0,'',oi)); }
const cwd={ isCollection:true, normal:false, showRelated:false, relatedDisplay:'none', z:(S.zoomedWin===w.wid?60:1), isText:false, isLogs:false, isShell:false, x:k.wx+w.dx, y:k.wy+w.dy, w:w.w, bodyH:Math.max(150,w.h-150), outline:this.rgba(k.color,.22), color:k.color, glyphClip:'none', title:this.collTitle(scope), status:(summ.bad?'Degraded':(summ.warn?'Pending':'Ready')), statusColor:this.statusColor(summ.bad?'Degraded':(summ.warn?'Pending':'Ready')),
total:summ.total, summOk:summ.ok, summWarn:summ.warn, summBad:summ.bad, count:visNodes.length, navMode:!!S.collNav[w.wid], searchVal:qv, listBg:(mode!=='tree'?A.accent:'transparent'), listFg:(mode!=='tree'?'#0b0e13':'#9fb0c8'), treeBg:(mode==='tree'?A.accent:'transparent'), treeFg:(mode==='tree'?'#0b0e13':'#9fb0c8'), rows, empty:rows.length===0,
onCollSearch:(e)=>this.setCollSearch(w.wid, e.target.value), onCollKey:(e)=>this.handleCollKey(e, k.id, w.wid, visNodes), onCollList:()=>this.setState(s=>({collView:Object.assign({},s.collView,{[w.wid]:'list'}), collSel:Object.assign({},s.collSel,{[w.wid]:0})})), onCollTree:()=>this.setState(s=>({collView:Object.assign({},s.collView,{[w.wid]:'tree'}), collSel:Object.assign({},s.collSel,{[w.wid]:0})})),
collInputRef:(el)=>{ if(el) this._collInputEls[w.wid]=el; }, onCollEnter:()=>{ this._hoverCollWid=w.wid; }, onCollLeave:()=>{ if(this._hoverCollWid===w.wid) this._hoverCollWid=null; },
onClose:()=>this.closeWindow(k.id,w.wid), onDrag:(e)=>this.startKrateDrag(k.id,e), onFocus:()=>this.zoomToggleWindow(k.id,w.wid), onWinEnter:()=>{ this._hoverWinId={kid:k.id,wid:w.wid}; }, onWinLeave:()=>{ if(this._hoverWinId&&this._hoverWinId.wid===w.wid) this._hoverWinId=null; }, onResize:(e)=>this.startWinResize(k.id,w.wid,e) };
this._collCtx[w.wid]={kid:k.id, nodes:visNodes};
wins.push(cwd); return; }
const wn=this.byId[w.objId||k.objId]; const m=this.meta(wn.type); const sc=this.shapeCss(m.shape); const tk=w.wid+'#'+w.tab;
const tabs=this.viewOrder().map(v=>{ const dis=!this.viewAvail(wn.type,v); const act=w.tab===v; return {label:VM[v].label,bg:act?'rgba(30,38,52,.9)':'transparent',fg:act?'#e6edf6':(dis?'#54607a':'#7e8aa2'),cursor:dis?'default':'pointer',opacity:dis?.45:1,onClick:()=>dis?null:this.setTab(k.id,w.wid,v)}; });
const isShell=w.tab==='shell'&&this.execable(wn.type); const isLogs=w.tab==='logs'&&this.execable(wn.type); const isText=!isShell&&!isLogs;
const wd={ normal:true, isCollection:false, showRelated:true, relatedDisplay:'inline-block', z:(S.zoomedWin===w.wid?60:1), x:k.wx+w.dx, y:k.wy+w.dy, w:w.w, bodyH:Math.max(120,w.h-76), outline:this.rgba(k.color,.22), color:m.color, glyphClip:sc.clip, title:VM[w.tab].label+' · '+wn.name, status:wn.status, statusColor:this.statusColor(wn.status), tabs, isShell, isLogs, isText, body:isText?this.winBody(wn,w.tab):'',
onClose:()=>this.closeWindow(k.id,w.wid), onDrag:(e)=>this.startKrateDrag(k.id,e), onFocus:()=>this.zoomToggleWindow(k.id,w.wid), onWinEnter:()=>{ this._hoverWinId={kid:k.id,wid:w.wid}; }, onWinLeave:()=>{ if(this._hoverWinId&&this._hoverWinId.wid===w.wid) this._hoverWinId=null; }, onResize:(e)=>this.startWinResize(k.id,w.wid,e),
onRelated:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.setState({relMenu:{kid:k.id, objId:w.objId||k.objId, sx:Math.min(e.clientX, W-244), sy:Math.min(e.clientY, H-260)}}); } };
if(isLogs){ const wid=w.wid; const buf=(S.logs[tk]||this.seedLogs(wn)); wd.logLines=buf.map(t=>({text:t,color:this.logColor(t)})); wd.logRef=(el)=>{ if(el) this._logEls[wid]=el; }; wd.onLogScroll=(e)=>{ const el=e.target; this._stickLog[wid]= el.scrollTop+el.clientHeight >= el.scrollHeight-14; }; }
if(isShell){ const wid=w.wid; const sess=(S.sh[tk]||this.seedShell(wn)); wd.shellLines=sess.lines.map(L=>({text:L.text,color:L.t==='in'?A.accent:(/not found|No such|Not a directory|failed|timeout/i.test(L.text)?'#ef6f6f':'#aab8cc')})); wd.promptStr=this.prompt(wn,sess.cwd); wd.shRef=(el)=>{ if(el) this._shEls[wid]=el; }; wd.onShScroll=(e)=>{ const el=e.target; this._stickSh[wid]= el.scrollTop+el.clientHeight >= el.scrollHeight-14; }; wd.onShellKey=(e)=>this.shellKey(k.id,wid,wn,e); wd.shInputRef=(el)=>{ if(el) this._shInputEls[wid]=el; }; wd.onShEnter=()=>{ this._hoverShWid=wid; }; wd.onShLeave=()=>{ if(this._hoverShWid===wid) this._hoverShWid=null; }; }
wins.push(wd); });
});
} else {
const L=this.cardLayout(); const drag=S.cardDrag;
S.krates.forEach((k,i)=>{ const col=i%L.cols, row=Math.floor(i/L.cols); let sx=L.startX+col*(L.cardW+L.gap), sy=L.startY+row*(L.cardH+L.gap); let dragging=false;
if(drag && drag.id===k.id && drag.mx!=null){ sx=drag.mx-L.cardW/2; sy=drag.my-L.cardH/2; dragging=true; }
cards.push({sx, sy, dragging, z:dragging?60:5, trans:dragging?'none':'left .18s cubic-bezier(.2,.8,.3,1), top .18s cubic-bezier(.2,.8,.3,1)', label:k.label, color:k.color, statusColor:this.statusColor(k.status), outline:this.rgba(k.color,.3), tint:this.rgba(k.color,.12),
badges:k.windows.map(w=>w.kind==='collection'?'▤':VM[w.tab].letter), count:k.windows.length, ns:(k.objId?this.byId[k.objId].ns:(k.collScope&&k.collScope.kind==='namespace'?k.collScope.value:'cluster')), onExpand:()=>this.flyToKrate(k.id), onDown:(e)=>this.startCardDrag(k.id,e), onClose:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.closeKrate(k.id); } }); });
}
const users=[{name:'Dana Okafor',color:'#6fb1ff'},{name:'Wen Li',color:'#5fd0c0'},{name:'Ravi Patel',color:'#e58fb0'},{name:'You',color:A.accent}];
const roster=users.map(u=>({name:u.name,color:u.color,initial:u.name==='You'?'★':u.name.split(' ').map(p=>p[0]).join('').slice(0,2)}));
const initialOf=(n)=> n==='You'?'★':n.split(' ').map(p=>p[0]).join('').slice(0,2);
const adminUsers=this.adminData().map(u=>{ const isYou=u.name==='You'; const color=isYou?A.accent:u.color;
const krs = isYou ? S.krates.map(k=>({label:k.label, color:k.color, badges:k.windows.map(w=>w.kind==='collection'?'▤':VM[w.tab].letter).join(' '), statusColor:this.statusColor(k.status), onSpectate:()=>{ this.setState({admin:false}); this.fitKrate(k.id); }})) : u.krates.map(kr=>({label:kr[0], color:color, badges:kr[1], statusColor:this.statusColor(kr[2]), onSpectate:()=>this.setState({admin:false})}));
return { name:u.name, color, initial:initialOf(u.name), border:this.rgba(color,.3), status:u.status, statusColor:u.statusColor, query:(isYou?(S.query||'—'):u.query), typing:(isYou?S.spotOpen:u.typing), krateCount:krs.length, krates:krs }; });
// spotlight
const results=this.buildResults(S.query,S.filterType); const sug=this.suggestType(S.query,S.filterType);
const resDisplay=results.map((it,i)=>{ const active=i===S.sel;
if(it.kind==='krate'){ const k=it.krate; const vk=k.windows.map(w=>w.kind==='collection'?'▤':VM[w.tab].letter).join(' ');
return { isKrate:true, id:k.id, name:k.label, sub:k.windows.length+' window'+(k.windows.length===1?'':'s')+' · '+vk, color:k.color, clip:'none', radius:'3px', glow:this.rgba(k.color,.5),
typeShort:'krate', isCrd:false, active, bg:active?A.dim:'transparent', views:[], viewKeys:'', enter:true, showBadge:true, badgeLabel:'KRATE',
viewHint:'open working set', onHover:()=>this.setState({sel:i}), onClick:()=>this.jumpToKrate(k.id) }; }
if(it.kind==='category'||it.kind==='namespace'){ const isNs=it.kind==='namespace'; const col=isNs?(it.color||'#6fb1ff'):this.meta(it.scope.value).color; const sc=isNs?{clip:'none',radius:'4px'}:this.shapeCss(this.meta(it.scope.value).shape);
const summ=this.statusSummary(this.collMembers(it.scope)); const sub=it.count+' objects · '+summ.ok+' ok'+(summ.warn?' · '+summ.warn+' pending':'')+(summ.bad?' · '+summ.bad+' degraded':'');
return { isColl:true, id:it.id, name:this.collTitle(it.scope), sub, color:col, clip:sc.clip, radius:sc.radius, glow:this.rgba(col,.5),
typeShort:isNs?'namespace':'category', collBadge:true, active, bg:active?A.dim:'transparent', views:[], viewKeys:'', enter:true, showBadge:true, badgeLabel:isNs?'NS':'CATEGORY',
viewHint:'open status view', onHover:()=>this.setState({sel:i}), onClick:()=>this.summonCollection(it.scope) }; }
const n=it.node; const m=this.meta(n.type); const sc=this.shapeCss(m.shape);
const sessionViews = (S.session && S.session.objId===n.id) ? S.session.views : [];
const avViews=this.viewOrder().filter(v=>this.viewAvail(n.type,v));
const views=avViews.map(v=>{ const opened=sessionViews.indexOf(v)>=0;
return { letter:'⌥'+VM[v].letter, label:VM[v].label, mark: opened?' ✓':'', cursor:'pointer', opacity:1,
border: opened?A.accent:'rgba(140,165,200,.22)', bg: opened?A.dim:'rgba(255,255,255,.02)', fg: opened?A.accent:'#dbe3ef',
kbdBg: opened?A.accent:'rgba(140,165,200,.16)', kbdFg: opened?'#0b0e13':'#9fb0c8',
onClick:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.openView(n.id,v,false); } }; });
return { isKrate:false, id:n.id, name:n.name, sub:m.label+' · ns/'+n.ns, color:m.color, clip:sc.clip, radius:sc.radius, glow:this.rgba(m.color,.5),
typeShort:m.short, isCrd:!!m.crd, active, bg:active?A.dim:'transparent', views, viewKeys:avViews.map(v=>'⌥'+VM[v].letter).join(' '), enter:false, showBadge:false, badgeLabel:'',
viewHint: 'hover or ↓, then ⌥ + letter',
onHover:()=>this.setState({sel:i}), onClick:()=>this.openView(n.id,this.defaultView(n.type),true) }; });
const chipLabel={deployment:'deploy',service:'svc',pod:'pods',secret:'secrets',configmap:'config',statefulset:'sts',daemonset:'ds',ingress:'ing',crd:'crd',pvc:'pvc'};
const chipTypes=[['','all']].concat(this.filterTypeOrder().map(t=>[t, chipLabel[t]||this.meta(t).short]));
const chips=chipTypes.map(ct=>{ const t=ct[0]; const m=t?this.meta(t):{color:'#8aa0bd'}; const sc=t?this.shapeCss(m.shape):{clip:'none',radius:'50%'}; const active=(S.filterType||'')===t;
return {label:ct[1],color:m.color,clip:sc.clip,radius:sc.radius,bg:active?A.dim:'rgba(255,255,255,.02)',fg:active?A.accent:'#9fb0c8',border:active?A.accent:'rgba(140,165,200,.18)',onClick:()=>{ this.recordType(t); this._focus=true; this.setState({filterType:t||null, sel:0, navigated:false}); }}; });
const fm=S.filterType?this.meta(S.filterType):null; const fsc=fm?this.shapeCss(fm.shape):null;
const selRow=resDisplay[S.sel]; const openKeysLabel=(selRow && selRow.viewKeys) ? selRow.viewKeys : (selRow && selRow.enter ? '⏎ open' : '⌥L ⌥S ⌥D ⌥Y');
const sessionActive=!!S.session; const buildingViews= S.session? S.session.views.map(v=>VM[v].letter):[];
let relMenu=null; if(S.relMenu){ const o=this.byId[S.relMenu.objId]; const items=(this.adj[S.relMenu.objId]||[]).map(id=>{ const rn=this.byId[id]; const rm=this.meta(rn.type); const rsc=this.shapeCss(rm.shape);
return {name:rn.name, typeShort:rm.short, color:rm.color, clip:rsc.clip, radius:rsc.radius, onClick:(e)=>{ if(e&&e.stopPropagation)e.stopPropagation(); this.openRelated(S.relMenu.kid, id); }}; });
relMenu={sx:S.relMenu.sx, sy:S.relMenu.sy, label:o.name, items, empty:items.length===0}; }
const zoomPct=Math.round(S.zoom*100);
// minimap
let minimap=null;
if(S.krates.length){ const fps=S.krates.map(k=>this.krateFootprint(k));
let bx=1e9,by=1e9,bx2=-1e9,by2=-1e9; fps.forEach(f=>{ bx=Math.min(bx,f.x); by=Math.min(by,f.y); bx2=Math.max(bx2,f.x+f.w); by2=Math.max(by2,f.y+f.h); });
const vx1=(-S.camX)/S.zoom, vy1=(-S.camY)/S.zoom, vx2=(W-S.camX)/S.zoom, vy2=(H-S.camY)/S.zoom;
bx=Math.min(bx,vx1); by=Math.min(by,vy1); bx2=Math.max(bx2,vx2); by2=Math.max(by2,vy2);
const pad=140; bx-=pad; by-=pad; bx2+=pad; by2+=pad; const bw=bx2-bx, bh=by2-by;
const mmW=196, mmH=132; const scale=Math.min(mmW/bw, mmH/bh); const offX=(mmW-bw*scale)/2, offY=(mmH-bh*scale)/2;
this._mm={bx:bx-offX/scale, by:by-offY/scale, scale}; this._mmW=mmW; this._mmH=mmH;
const mapX=(x)=>(x-bx)*scale+offX, mapY=(y)=>(y-by)*scale+offY;
const rects=S.krates.map((k,i)=>{ const f=fps[i]; return {x:mapX(f.x), y:mapY(f.y), w:Math.max(6,f.w*scale), h:Math.max(5,f.h*scale), color:k.color, collapsed:!!k.collapsed}; });
minimap={ w:mmW, h:mmH, rects, vx:mapX(vx1), vy:mapY(vy1), vw:(vx2-vx1)*scale, vh:(vy2-vy1)*scale, onDown:(e)=>{ e.stopPropagation(); this.mmJump(e); }, setEl:(el)=>{ this._mmEl=el; } };
} else { this._mm=null; }
return {
cursor:S.dragging?'grabbing':(S.spacePan?'grab':'default'),
worldTransform:'translate('+S.camX+'px,'+S.camY+'px) scale('+S.zoom+')',
worldTransition:S.flying?'transform .52s cubic-bezier(.22,.8,.28,1)':'none',
accent:A.accent, accentGlow:A.glow, accentDim:A.dim,
frames, wins, cards, minis, minimap, roster, relMenu, krateCount:S.krates.length,
admin:S.admin, adminUsers, adminCount:adminUsers.length, onToggleAdmin:this.toggleAdmin,
adminBtnBg:S.admin?A.dim:'rgba(14,18,25,.82)', adminBtnBorder:S.admin?A.accent:'rgba(140,165,200,.18)', adminBtnFg:S.admin?A.accent:'#7e8aa2',
emptyCanvas: S.krates.length===0 && !S.spotOpen,
zoomLabel: (S.collapsed?'overview · ':'')+zoomPct+'%', zoomDotColor: S.collapsed?A.accent:'#4ad07a',
spotOpen:S.spotOpen, query:S.query, sel:S.sel,
hasFilter:!!S.filterType, filterLabel:fm?fm.label:'', filterColor:fm?fm.color:'#8aa0bd', filterClip:fsc?fsc.clip:'none', filterRadius:fsc?fsc.radius:'50%',
placeholder:S.filterType?('search '+fm.label.toLowerCase()+'s…'):'Search pods, services, secrets, CRDs…',
ghost: sug? this.meta(sug).label : '',
chips, results:resDisplay, noResults:S.query.trim()!=='' && resDisplay.length===0,
resultsHeader: S.query.trim()? 'results' : (S.filterType? (fm.label.toLowerCase()+'s') : 'top objects'),
enterLabel: S.session? 'place' : 'open default', openKeysLabel, sessionActive, buildingViews,
onSearchChange:this.onSearchChange, onSearchKeydown:this.onSearchKeydown, setInput:this.setInput, onSpotBackdrop:this.onSpotBackdrop,
onBgDown:this.onBgDown, onRootDownCapture:this.onRootDownCapture, onWheel:this.onWheel, setRoot:this.setRoot, stopDown:this.stopDown, winWheel:this.winWheel };
}
}
</script>
</body>
</html>