infinicode 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -7,7 +7,19 @@
|
|
|
7
7
|
* POST /fed/command and POST /fed/apply. The browser authenticates every request
|
|
8
8
|
* with the ?token= it was loaded with (browsers can't set Bearer on navigation).
|
|
9
9
|
*
|
|
10
|
+
* Three tabs:
|
|
11
|
+
* Fleet — node cards + this-node hardware + live activity feed
|
|
12
|
+
* Park — isometric live map of the mesh (nodes placed on a floor, agent
|
|
13
|
+
* movement + tool-use pulses streamed from /fed/stream; click a robot
|
|
14
|
+
* for its cam / mic / speaker / hardware drawer)
|
|
15
|
+
* Sessions & Telemetry — RoboPark scheduler stats + production_mode + controls
|
|
16
|
+
*
|
|
17
|
+
* The Park cam panel targets a LiveKit video track: it fetches a per-robot stream
|
|
18
|
+
* token from /robopark/api/robots/:id/stream and, when the livekit-client SDK and
|
|
19
|
+
* a published track are present, subscribes; otherwise it shows a styled preview.
|
|
20
|
+
* That token/track is produced by the robot-side RoboPark plugin.
|
|
21
|
+
*
|
|
10
22
|
* Kept free of backticks and ${...} so the whole page fits inside this TS
|
|
11
23
|
* template literal without escaping; the client JS uses string concatenation.
|
|
12
24
|
*/
|
|
13
|
-
export declare const DASHBOARD_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>RoboPark Control</title>\n<style>\n:root{\n --bg:#FFF8DE; --panel:#FBF1CE; --panel-2:#F3E4B4; --border:#E0C982; --border-strong:#B97816;\n --ink:#231704; --muted:#6E4A12; --gold:#7A420A; --gold-bright:#B97816;\n --ok:#2F7A32; --ok-bg:#E4F0DD; --warn:#9A6A12; --warn-bg:#F6E9C4; --bad:#B5382E; --bad-bg:#F6DED9;\n --info:#1F6F9E; --info-bg:#DCEEF7;\n --mono:ui-monospace,\"Cascadia Code\",Consolas,monospace;\n --sans:system-ui,\"Segoe UI\",Roboto,sans-serif;\n}\n@media (prefers-color-scheme:dark){:root{\n --bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;\n --ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;\n --info:#6FB6FF; --info-bg:#0F2231;\n}}\n:root[data-theme=\"dark\"]{\n --bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;\n --ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;\n --info:#6FB6FF; --info-bg:#0F2231;\n}\n:root[data-theme=\"light\"]{\n --bg:#FFF8DE; --panel:#FBF1CE; --panel-2:#F3E4B4; --border:#E0C982; --border-strong:#B97816;\n --ink:#231704; --muted:#6E4A12; --gold:#7A420A; --gold-bright:#B97816;\n --ok:#2F7A32; --ok-bg:#E4F0DD; --warn:#9A6A12; --warn-bg:#F6E9C4; --bad:#B5382E; --bad-bg:#F6DED9;\n --info:#1F6F9E; --info-bg:#DCEEF7;\n}\n*{box-sizing:border-box;margin:0;padding:0}\nhtml,body{height:100%}\nbody{background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}\n.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}\n\n/* top bar */\nheader.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;\n padding:0.7rem 1.1rem;border-bottom:1px solid var(--border);background:var(--panel);position:sticky;top:0;z-index:5;}\n.brand{display:flex;align-items:center;gap:0.6rem;}\n.livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 70%,transparent);animation:pulse 2.4s infinite;flex:none;}\n.livedot.stale{background:var(--muted);animation:none;}\n@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}70%{box-shadow:0 0 0 7px transparent}100%{box-shadow:0 0 0 0 transparent}}\n@media (prefers-reduced-motion:reduce){.livedot{animation:none}}\n.brand b{font-size:0.98rem;letter-spacing:-0.01em;}\n.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}\n.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--border);\n background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}\n.chip b{color:var(--ink);font-weight:600;}\n.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}\n.themebtn{font-family:var(--mono);font-size:0.68rem;background:transparent;border:1px solid var(--border);color:var(--muted);\n border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}\n.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n\n/* layout */\n.wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}\n@media (max-width:920px){.wrap{grid-template-columns:1fr}}\nsection h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}\nsection h2 .count{color:var(--muted);}\n\n/* fleet cards */\n.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}\n.node{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--muted);border-radius:9px;\n padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s;}\n.node:hover{transform:translateY(-1px);}\n.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.72;}\n.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}\n.node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}\n.node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}\n.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}\n.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);\n border-radius:4px;padding:0 0.3em;}\n.bar-track{height:6px;border-radius:4px;background:var(--panel-2);overflow:hidden;margin-bottom:0.3rem;}\n.bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;}\n.bar-fill.warn{background:var(--warn)} .bar-fill.bad{background:var(--bad)}\n.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}\n.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}\n.cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border-radius:4px;padding:0.05em 0.35em;}\n.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:9px;}\n\n/* hardware meters */\n.hw{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:0.85rem;margin-top:1rem;}\n.meter{margin-bottom:0.65rem;}\n.meter:last-child{margin-bottom:0;}\n.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}\n.meter .lab b{color:var(--ink);}\n\n/* activity feed */\naside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}\n.feed{background:var(--panel);border:1px solid var(--border);border-radius:9px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;}\n.line{padding:0.28rem 0.6rem;border-bottom:1px solid color-mix(in srgb,var(--border) 55%,transparent);display:flex;gap:0.5rem;line-height:1.3;}\n.line:last-child{border-bottom:none;}\n.line .t{color:var(--muted);flex:none;}\n.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.line .d{color:var(--ink);flex:1;word-break:break-word;}\n.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}\n\n/* command bar */\nfooter.cmd{position:fixed;left:0;right:0;bottom:0;background:var(--panel);border-top:1px solid var(--border-strong);\n padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;z-index:6;}\nfooter .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}\nfooter .sel b{color:var(--ink);}\nfooter input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:var(--bg);color:var(--ink);\n border:1px solid var(--border);border-radius:7px;padding:0.5em 0.7em;}\nfooter input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\nbutton.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:7px;padding:0.5em 0.85em;\n border:1px solid var(--border-strong);background:var(--gold-bright);color:#231704;white-space:nowrap;}\nbutton.act.ghost{background:transparent;color:var(--gold);}\nbutton.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}\nbutton.act:disabled{opacity:0.5;cursor:default;}\n#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;background:var(--panel-2);\n border:1px solid var(--border-strong);border-radius:9px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;\n white-space:pre-wrap;display:none;box-shadow:0 8px 30px rgba(0,0,0,0.25);z-index:7;}\n#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}\n.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;}\n\n/* tabs + product panes */\n.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--border);background:var(--panel);position:sticky;top:53px;z-index:4;}\n.tab{font-family:var(--mono);font-size:0.74rem;padding:0.6rem 0.95rem;cursor:pointer;color:var(--muted);background:none;border:none;border-bottom:2px solid transparent;}\n.tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);}\n.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.pane{display:none;} .pane.active{display:block;}\n.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}\n.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}\n.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}\n.rp-card{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--gold-bright);border-radius:9px;padding:0.75rem 0.85rem;}\n.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}\n.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}\n.rp-m{display:flex;flex-direction:column;}\n.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}\n.rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}\n.rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}\n.pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.pm .state{font-weight:700;letter-spacing:0.03em;}\n.pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}\n.toggle{position:relative;width:42px;height:22px;border-radius:22px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;padding:0;transition:background .15s;}\n.toggle::after{content:\"\";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}\n.toggle[aria-checked=\"true\"]{background:var(--ok-bg);border-color:var(--ok);}\n.toggle[aria-checked=\"true\"]::after{transform:translateX(20px);background:var(--ok);}\n.toggle:disabled{opacity:0.45;cursor:default;}\n.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}\n.rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:6px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}\n.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}\n.rp-btn:disabled{opacity:0.4;cursor:default;}\n.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}\n</style>\n</head>\n<body>\n<div class=\"offline\" id=\"offline\">connection lost \u2014 retrying</div>\n<header class=\"bar\">\n <div class=\"brand\">\n <span class=\"livedot\" id=\"livedot\"></span>\n <div>\n <b>RoboPark Control</b>\n <div class=\"who mono\" id=\"who\">connecting\u2026</div>\n </div>\n </div>\n <div class=\"chips\" id=\"summary\"></div>\n <button class=\"themebtn\" id=\"theme\">Theme</button>\n</header>\n\n<div class=\"tabs\">\n <button class=\"tab active\" data-tab=\"fleet\" id=\"tabbtn-fleet\">Fleet</button>\n <button class=\"tab\" data-tab=\"robopark\" id=\"tabbtn-robopark\">Sessions & Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <h2>Fleet <span class=\"count\" id=\"fleetcount\"></span></h2>\n <div class=\"fleet\" id=\"fleet\"></div>\n <div class=\"hw\" id=\"hw\"></div>\n </section>\n <aside>\n <h2>Live activity</h2>\n <div class=\"feed\" id=\"feed\"></div>\n </aside>\n</div>\n</div>\n\n<div class=\"pane\" id=\"pane-robopark\">\n <div class=\"rp-head\">\n <h2 style=\"margin:0\">RoboPark \u00B7 sessions & telemetry</h2>\n <div class=\"rp-controls\">\n <span class=\"pm\" id=\"pm-wrap\" hidden>\n <span>production mode</span>\n <button class=\"toggle\" id=\"pm-toggle\" role=\"switch\" aria-checked=\"false\" title=\"master enable \u2014 robots only join sessions when ON\"></button>\n <span class=\"state off\" id=\"pm-state\">off</span>\n </span>\n <div class=\"rp-totals\" id=\"rp-totals\"></div>\n </div>\n </div>\n <div class=\"rp-grid\" id=\"rp-grid\"></div>\n</div>\n\n<footer class=\"cmd\">\n <span class=\"sel\">on <b id=\"selName\">this node</b></span>\n <input id=\"cmdInput\" placeholder=\"/status /nodes /health (Enter to run)\" autocomplete=\"off\" spellcheck=\"false\">\n <button class=\"act\" id=\"runBtn\">Run</button>\n <button class=\"act ghost\" id=\"applyBtn\">Apply config to fleet</button>\n</footer>\n<div id=\"out\"><button class=\"x\" id=\"outClose\">close</button><span id=\"outText\"></span></div>\n\n<script>\n(function(){\n var TOKEN = new URLSearchParams(location.search).get('token') || '';\n var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n\n var $ = function(id){ return document.getElementById(id); };\n function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }\n function fmtBytes(b){ if(!b) return '0'; var u=['B','KB','MB','GB','TB']; var i=Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i?1:0)+u[i]; }\n function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }\n function clock(ts){ var d=new Date(ts); function p(n){return (n<10?'0':'')+n;} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }\n\n // theme\n (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}\n if(s) document.documentElement.setAttribute('data-theme', s);\n $('theme').addEventListener('click', function(){\n var m=window.matchMedia('(prefers-color-scheme: dark)');\n var cur=document.documentElement.getAttribute('data-theme')||(m.matches?'dark':'light');\n var nx=cur==='dark'?'light':'dark';\n document.documentElement.setAttribute('data-theme', nx);\n try{localStorage.setItem(k,nx)}catch(e){}\n });\n })();\n\n // Pull a short human detail out of an event's data payload.\n function detailOf(data){\n var p = data || {};\n if(typeof p==='string') return p;\n if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' \u2713':' \u2717'):'');\n if(p.model) return String(p.model);\n if(p.provider) return String(p.provider);\n if(p.reason) return String(p.reason).slice(0,60);\n if(p.description) return String(p.description).slice(0,50);\n if(p.name) return String(p.name);\n return '';\n }\n function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }\n\n function summarizeFrame(f){\n var d = f.d || {};\n if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% \u00B7 mem '+m+'%'; }\n if(f.t==='hb'){ return 'heartbeat \u00B7 load '+(d.load!=null?d.load:'-'); }\n if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }\n // routing decision: compact payload is {k, m, task, w, d}\n if(f.t==='rt'){ var rp=d.d||{}; return 'route \u2192 '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }\n // immediate lifecycle event: {k:type, m, task, w, d:payload}\n if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }\n // coalesced chatter: {count, tail:[{k, d}]}\n if(f.t==='log'){\n if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);\n return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' \u00D7'+d.count):''); }\n return 'log';\n }\n return f.t;\n }\n\n function renderFeed(activity){\n var feed = $('feed');\n if(!activity || !activity.length){\n if(!feed.dataset.seeded){ feed.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">waiting for fleet activity \u2014 task runs, routing, and peer events stream here.</span></div>'; }\n return;\n }\n feed.dataset.seeded = '1';\n var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;\n var html = '';\n for(var i=0;i<activity.length;i++){\n var e = activity[i]; var f = e.f || e; if(f.s!=null && f.s<=lastSeq && f.t!=='hw') { /* keep */ }\n var name = (f.n||'').slice(0,10);\n html += '<div class=\"line k-'+esc(f.t)+'\"><span class=\"t\">'+clock(f.ts)+'</span>'\n + '<span class=\"n\" title=\"'+esc(f.n)+'\">'+esc(name)+'</span>'\n + '<span class=\"d\">'+esc(summarizeFrame(f))+'</span></div>';\n }\n feed.innerHTML = html;\n if(near) feed.scrollTop = feed.scrollHeight;\n }\n\n function meter(label, value, sub){\n var v = Math.max(0, Math.min(100, Math.round(value||0)));\n return '<div class=\"meter\"><div class=\"lab\"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'\n + (sub?(' '+esc(sub)):'')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div></div>';\n }\n\n function renderHw(hw){\n var el = $('hw');\n if(!hw){ el.innerHTML = '<div class=\"meter\"><div class=\"lab\"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }\n var html = '<h2 style=\"margin-top:0\">This node \u00B7 '+esc(hw.system?hw.system.hostname:'')+'</h2>';\n html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');\n html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');\n if(hw.gpu){ html += meter('GPU '+esc(hw.gpu.name||hw.gpu.vendor||''), hw.gpu.utilizationPercent, hw.gpu.vramTotalBytes?(fmtBytes(hw.gpu.vramUsedBytes)+' / '+fmtBytes(hw.gpu.vramTotalBytes)+' vram'):''); }\n el.innerHTML = html;\n }\n\n function selectNode(id, name){\n selected = (id===selfId)?null:id;\n $('selName').textContent = selected ? name : 'this node';\n var cards = document.querySelectorAll('.node');\n for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);\n }\n\n function renderFleet(nodes){\n var el = $('fleet');\n if(!nodes || !nodes.length){ el.innerHTML = '<div class=\"empty\">No nodes reporting yet. Start peers with <span class=\"mono\">infinicode serve</span> or <span class=\"mono\">robopark setup</span>.</div>'; return; }\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var html = '';\n for(var i=0;i<nodes.length;i++){\n var n = nodes[i];\n var isSelf = n.nodeId===selfId;\n var load = n.load!=null?n.load:0;\n var caps = (n.capabilities||[]).slice(0,6);\n var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class=\"cap\">'+esc(caps[c])+'</span>';\n var site = n.site?('\u00B7 '+esc(n.site)):'';\n html += '<div class=\"node '+(n.connected?'up':'down')+(selected===n.nodeId?' sel':'')+'\" tabindex=\"0\" role=\"button\" data-id=\"'+esc(n.nodeId)+'\" data-name=\"'+esc(n.displayName)+'\">'\n + '<div class=\"top\"><span class=\"name\">'+esc(n.displayName)+(isSelf?' <span class=\"selfbadge\">this</span>':'')+'</span><span class=\"role\">'+esc(n.role)+'</span></div>'\n + '<div class=\"meta\">'+esc(n.platform||'')+' '+site+' <span class=\"mono\">v'+esc(n.version||'?')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(load)+'\" style=\"width:'+(n.connected?load:0)+'%\"></div></div>'\n + '<div class=\"load\"><span>'+(n.connected?'load '+Math.round(load)+'%':'offline')+'</span><span>'+((n.commands||[]).length)+' cmds</span></div>'\n + (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'')\n + '</div>';\n }\n el.innerHTML = html;\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }\n card.addEventListener('click', pick);\n card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });\n })(cards[j]);\n }\n }\n\n function renderSummary(s){\n var nodes = s.nodes||[];\n var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;\n var down = nodes.length-up;\n var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;\n var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});\n var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;\n $('summary').innerHTML =\n '<span class=\"chip ok\">nodes up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">active runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>';\n $('fleetcount').textContent = '(' + nodes.length + ')';\n var self = s.self||{};\n var role = s.role||{};\n $('who').textContent = (self.displayName||'node') + ' \u00B7 ' + (self.role||'?') + (role.site?(' \u00B7 '+role.site):'') + ' \u00B7 v' + (self.version||'?');\n }\n\n function setLive(ok){\n $('livedot').classList.toggle('stale', !ok);\n $('offline').style.display = (!ok && everOk) ? 'block' : 'none';\n }\n\n function poll(){\n fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){\n if(!r.ok) throw new Error('status '+r.status);\n return r.json();\n }).then(function(s){\n everOk = true; setLive(true);\n if(!selfId && s.self) selfId = s.self.nodeId;\n if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }\n renderSummary(s);\n renderFleet(s.nodes||[]);\n renderHw(s.hardware);\n renderFeed(s.activity||[]);\n }).catch(function(){ setLive(false); });\n }\n\n // command execution\n function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }\n $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });\n\n function runCommand(input){\n input = (input||'').trim();\n if(!input) return;\n // Target a peer by prefixing the kernel's node-scoped command form.\n var payload = selected ? ('/node ' + selected + ' ' + input) : input;\n $('runBtn').disabled = true;\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })\n .catch(function(e){ showOut('request failed: '+e.message); })\n .then(function(){ $('runBtn').disabled=false; });\n }\n\n $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });\n $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });\n $('applyBtn').addEventListener('click', function(){\n $('applyBtn').disabled = true;\n fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })\n .catch(function(e){ showOut('apply failed: '+e.message); })\n .then(function(){ $('applyBtn').disabled=false; });\n });\n\n // \u2500\u2500 tabs: Fleet + RoboPark sessions/telemetry \u2500\u2500\n var tabs = document.querySelectorAll('.tab');\n var activeTab = 'fleet';\n for (var ti=0; ti<tabs.length; ti++){\n (function(btn){\n btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });\n })(tabs[ti]);\n }\n function selectTab(name){\n activeTab = name;\n for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);\n $('pane-fleet').classList.toggle('active', name==='fleet');\n $('pane-robopark').classList.toggle('active', name==='robopark');\n if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark') pollRobopark();\n }\n // deep-link: open the tab named in the URL hash on load\n if (location.hash.replace('#','') === 'robopark') selectTab('robopark');\n\n function robotsFrom(d){\n if (Array.isArray(d)) return d;\n if (d && Array.isArray(d.robots)) return d.robots;\n return [];\n }\n // Reflect this node's read-only state on the RoboPark controls.\n function applyReadOnly(){\n var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;\n var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;\n var note=$('rp-ro-note');\n if(rpReadOnly && !note){\n note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';\n note.textContent='read-only (no token) \u2014 start the hub with --token to enable control';\n var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);\n } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }\n }\n\n // A write through the scheduler proxy. Handles the read-only 403 gracefully.\n function rpAction(method, path, body){\n if(rpReadOnly){ showOut('This node is read-only \u2014 restart the hub with --token to enable control actions.'); return Promise.resolve(null); }\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: body?{'content-type':'application/json'}:undefined,\n body: body?JSON.stringify(body):undefined\n }).then(function(r){\n if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }\n return r.json().catch(function(){ return {}; }).then(function(j){ if(!r.ok) throw new Error((j&&(j.error||j.detail))||('status '+r.status)); return j; });\n });\n }\n\n // production_mode master toggle\n function setPm(on){\n var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');\n if(!wrap) return;\n wrap.hidden=false;\n tg.setAttribute('aria-checked', on?'true':'false');\n st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');\n }\n (function(){\n var tg=$('pm-toggle'); if(!tg) return;\n tg.addEventListener('click', function(){\n if(rpReadOnly||pmBusy) return;\n var next = tg.getAttribute('aria-checked')!=='true';\n pmBusy=true; tg.disabled=true;\n rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){\n setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);\n }).catch(function(e){ showOut('production_mode: '+e.message); })\n .then(function(){ pmBusy=false; applyReadOnly(); });\n });\n })();\n function pollSettings(){\n fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){\n if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);\n }).catch(function(){});\n }\n\n function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }\n function endSession(id){\n if(!confirm('End the active session for '+id+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+id); pollRobopark();\n }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });\n }\n // event-delegated action buttons (cards are re-rendered each poll)\n $('rp-grid').addEventListener('click', function(e){\n var b=e.target.closest?e.target.closest('.rp-btn'):null; if(!b) return;\n var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');\n if(act==='end') endSession(id);\n });\n\n function pollRobopark(){\n pollSettings();\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){\n var grid = $('rp-grid'), totals = $('rp-totals');\n if (d && (d.disabled || d.ok===false)){\n totals.innerHTML=''; $('pm-wrap').hidden=true;\n grid.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1\">'+esc(d.error||'no scheduler linked')+' \u2014 start the hub with <span class=\"mono\">--scheduler-url http://<scheduler>:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n var tot = (d && d.totals) || {};\n var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);\n var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);\n totals.innerHTML =\n '<span class=\"chip\">robots <b>'+robots.length+'</b></span>'\n + '<span class=\"chip\">sessions <b>'+totalSessions+'</b></span>'\n + '<span class=\"chip\">triggers <b>'+totalTrig+'</b></span>';\n if (!robots.length){ grid.innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">No robot telemetry yet \u2014 it populates as robots trigger sessions.</div>'; return; }\n var html='';\n for (var i=0;i<robots.length;i++){\n var x = robots[i];\n var name = robotName(x);\n var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014';\n var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';\n var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'\u2014';\n var active = x.active_session||x.in_session||x.session_active;\n var idAttr = esc(name);\n html += '<div class=\"rp-card\"><h4>'+esc(name)+(active?' <span class=\"chip\" style=\"color:var(--ok)\">live</span>':'')+'</h4><div class=\"rp-metrics\">'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+lat+'</span><span class=\"l\">avg latency</span></div>'\n + '<div class=\"rp-m\"><span class=\"v '+dcls+'\">'+drop+'</span><span class=\"l\">drop rate</span></div>'\n + '</div><div class=\"rp-actions\">'\n + '<button class=\"rp-btn danger\" data-act=\"end\" data-robot=\"'+idAttr+'\"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'\n + '</div></div>';\n }\n grid.innerHTML = html;\n }).catch(function(){\n $('rp-grid').innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">telemetry unreachable</div>';\n });\n }\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n})();\n</script>\n</body>\n</html>";
|
|
25
|
+
export declare const DASHBOARD_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>RoboPark Control</title>\n<style>\n/* \u2500\u2500 premium tech glass theme \u2500\u2500 dark is the committed default; light is a frosted toggle \u2500\u2500 */\n:root{\n --bg:#08070F;\n --grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),\n radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),\n radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),\n linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);\n --panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;\n --glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);\n --border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);\n --ink:#F2ECDC; --muted:#9E93B4;\n --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);\n --bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);\n --shadow: 0 10px 34px rgba(0,0,0,0.42);\n --mono:ui-monospace,\"Cascadia Code\",Consolas,monospace;\n --sans:system-ui,\"Segoe UI\",Roboto,sans-serif;\n}\n@media (prefers-color-scheme:dark){:root{}}\n:root[data-theme=\"dark\"]{}\n:root[data-theme=\"light\"]{\n --bg:#EDEAF6;\n --grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),\n radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),\n linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);\n --panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;\n --glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);\n --border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);\n --ink:#241A08; --muted:#6E5A3E;\n --gold:#8A5A0C; --gold-bright:#B9781A;\n --ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);\n --bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);\n --shadow: 0 10px 30px rgba(60,40,10,0.14);\n}\n*{box-sizing:border-box;margin:0;padding:0}\nhtml,body{height:100%}\nbody{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;\n color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}\n.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}\n.glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n\n/* top bar */\nheader.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;\n padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}\n.brand{display:flex;align-items:center;gap:0.6rem;}\n.livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 2.4s infinite;flex:none;}\n.livedot.stale{background:var(--muted);box-shadow:none;animation:none;}\n@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent),0 0 12px var(--ok)}70%{box-shadow:0 0 0 7px transparent,0 0 12px var(--ok)}100%{box-shadow:0 0 0 0 transparent,0 0 12px var(--ok)}}\n@media (prefers-reduced-motion:reduce){.livedot{animation:none}}\n.brand b{font-size:0.98rem;letter-spacing:-0.01em;}\n.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}\n.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);\n background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}\n.chip b{color:var(--ink);font-weight:600;}\n.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}\n.themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);\n border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}\n.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n\n/* layout */\n.wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}\n@media (max-width:920px){.wrap{grid-template-columns:1fr}}\nsection h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}\nsection h2 .count{color:var(--muted);}\n\n/* fleet cards */\n.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}\n.node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.node:hover{transform:translateY(-2px);border-color:var(--border-strong);}\n.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}\n.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}\n.node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}\n.node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}\n.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}\n.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);\n border-radius:4px;padding:0 0.3em;}\n.bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}\n:root[data-theme=\"light\"] .bar-track{background:rgba(90,70,20,0.14);}\n.bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;box-shadow:0 0 8px color-mix(in srgb,var(--ok) 60%,transparent);}\n.bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}\n.bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}\n.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}\n.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}\n.cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border:1px solid var(--glass-border);border-radius:5px;padding:0.05em 0.4em;}\n.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}\n\n/* hardware meters */\n.hw{border-radius:13px;padding:0.85rem;margin-top:1rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.meter{margin-bottom:0.65rem;}\n.meter:last-child{margin-bottom:0;}\n.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}\n.meter .lab b{color:var(--ink);}\n\n/* activity feed */\naside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}\n.feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}\n.line:last-child{border-bottom:none;}\n.line .t{color:var(--muted);flex:none;}\n.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.line .d{color:var(--ink);flex:1;word-break:break-word;}\n.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}\n\n/* command bar */\nfooter.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n border-top:1px solid var(--border-strong);padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;box-shadow:inset 0 1px 0 var(--glass-hi);}\nfooter .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}\nfooter .sel b{color:var(--ink);}\nfooter input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);\n border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}\n:root[data-theme=\"light\"] footer input{background:rgba(255,255,255,0.6);}\nfooter input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\nbutton.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;\n border:1px solid var(--border-strong);background:linear-gradient(180deg,var(--gold-bright),var(--gold));color:#231704;white-space:nowrap;box-shadow:0 4px 14px rgba(255,180,60,0.28);}\nbutton.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}\nbutton.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}\nbutton.act:disabled{opacity:0.5;cursor:default;}\n#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;\n background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);\n border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;\n white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}\n#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}\n.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;\n border-bottom:1px solid var(--glass-border);}\n\n/* tabs + product panes */\n.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);\n background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);\n position:sticky;top:53px;z-index:19;}\n.tab{font-family:var(--mono);font-size:0.74rem;padding:0.6rem 0.95rem;cursor:pointer;color:var(--muted);background:none;border:none;border-bottom:2px solid transparent;}\n.tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);text-shadow:0 0 14px color-mix(in srgb,var(--gold-bright) 55%,transparent);}\n.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.pane{display:none;} .pane.active{display:block;}\n.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}\n.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}\n.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}\n.rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);\n background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}\n.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}\n.rp-m{display:flex;flex-direction:column;}\n.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}\n.rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}\n.rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}\n.pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.pm .state{font-weight:700;letter-spacing:0.03em;}\n.pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}\n.toggle{position:relative;width:42px;height:22px;border-radius:22px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;padding:0;transition:background .15s;}\n.toggle::after{content:\"\";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}\n.toggle[aria-checked=\"true\"]{background:var(--ok-bg);border-color:var(--ok);}\n.toggle[aria-checked=\"true\"]::after{transform:translateX(20px);background:var(--ok);}\n.toggle:disabled{opacity:0.45;cursor:default;}\n.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}\n.rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:7px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}\n.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}\n.rp-btn:disabled{opacity:0.4;cursor:default;}\n.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}\n\n/* \u2500\u2500 Park (isometric live map) \u2500\u2500 */\n#pane-park{padding:0;}\n.pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}\n#pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}\n#pk-canvas:active{cursor:grabbing;}\n.pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;\n display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);\n border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}\n.pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);\n padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}\n.pk-feed .rows{overflow-y:auto;padding:0.25rem;}\n.pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}\n.pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}\n.pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}\n.pk-row .who{font-family:var(--mono);font-size:0.64rem;color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}\n.pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}\n.pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}\n.pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}\n.pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);\n background:var(--panel);border:1px solid var(--glass-border);border-radius:9px;padding:0.45rem 0.65rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);}\n.pk-hint b{color:var(--gold-bright);}\n.pk-legend{position:absolute;left:12px;top:12px;z-index:6;display:flex;gap:0.5rem;flex-wrap:wrap;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.pk-legend span{background:var(--panel);border:1px solid var(--glass-border);border-radius:100px;padding:0.2rem 0.55rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);display:inline-flex;gap:0.35rem;align-items:center;}\n.pk-legend i{width:8px;height:8px;border-radius:2px;}\n\n/* robot drawer */\n.pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;\n background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);\n border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);\n transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}\n.pk-drawer.open{transform:translateX(0);}\n.pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}\n.pk-av{width:40px;height:40px;border-radius:11px;background:radial-gradient(circle at 30% 25%,rgba(255,214,112,0.28),rgba(0,0,0,0.25));\n border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}\n.pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}\n.pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}\n.pk-dh .x{margin-left:auto;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;width:30px;height:30px;cursor:pointer;font-size:1rem;}\n.pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}\n.pk-pill{font-family:var(--mono);font-size:0.58rem;text-transform:uppercase;letter-spacing:0.04em;padding:0.16em 0.5em;border-radius:100px;border:1px solid;}\n.pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}\n.pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}\n.pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}\n.pk-sect{border-radius:12px;padding:0.75rem 0.8rem;background:var(--panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-hi);}\n.pk-sect h4{font-family:var(--mono);font-size:0.58rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.55rem;display:flex;align-items:center;gap:0.4rem;}\n.pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}\n.pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}\n.pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}\n.pk-cam .rec{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:0.35rem;color:#fff;text-shadow:0 1px 3px #000;}\n.pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}\n.pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}\n.pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}\n.pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}\n.pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}\n.pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}\n.pk-val{font-family:var(--mono);font-size:0.66rem;color:var(--ink);width:52px;text-align:right;flex:none;font-variant-numeric:tabular-nums;}\n.pk-wave{height:32px;width:100%;}\n.pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}\n.pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}\n.pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}\n.pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}\n.pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}\n.pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}\n.pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}\n.pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}\n.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}\n</style>\n</head>\n<body>\n<div class=\"offline\" id=\"offline\">connection lost \u2014 retrying</div>\n<header class=\"bar\">\n <div class=\"brand\">\n <span class=\"livedot\" id=\"livedot\"></span>\n <div>\n <b>RoboPark Control</b>\n <div class=\"who mono\" id=\"who\">connecting\u2026</div>\n </div>\n </div>\n <div class=\"chips\" id=\"summary\"></div>\n <button class=\"themebtn\" id=\"theme\">Theme</button>\n</header>\n\n<div class=\"tabs\">\n <button class=\"tab active\" data-tab=\"fleet\" id=\"tabbtn-fleet\">Fleet</button>\n <button class=\"tab\" data-tab=\"park\" id=\"tabbtn-park\">Park</button>\n <button class=\"tab\" data-tab=\"robopark\" id=\"tabbtn-robopark\">Sessions & Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <h2>Fleet <span class=\"count\" id=\"fleetcount\"></span></h2>\n <div class=\"fleet\" id=\"fleet\"></div>\n <div class=\"hw\" id=\"hw\"></div>\n </section>\n <aside>\n <h2>Live activity</h2>\n <div class=\"feed\" id=\"feed\"></div>\n </aside>\n</div>\n</div>\n\n<div class=\"pane\" id=\"pane-park\">\n <div class=\"pk-stage\" id=\"pk-stage\">\n <canvas id=\"pk-canvas\"></canvas>\n <div class=\"pk-legend\">\n <span><i style=\"background:var(--gold-bright)\"></i>hub</span>\n <span><i style=\"background:var(--ok);border-radius:50%\"></i>robot</span>\n <span><i style=\"background:var(--info)\"></i>peer</span>\n <span><i style=\"background:var(--info)\"></i>dispatch</span>\n <span><i style=\"background:var(--ok)\"></i>telemetry</span>\n </div>\n <aside class=\"pk-feed\">\n <h3><span>Live activity</span><span style=\"color:var(--muted)\" id=\"pk-src\">/fed/stream</span></h3>\n <div class=\"rows\" id=\"pk-rows\"></div>\n </aside>\n <div class=\"pk-hint\">drag to pan \u00B7 scroll to zoom \u00B7 <b>click a robot</b> for cam / mic / speaker</div>\n </div>\n <div class=\"pk-drawer\" id=\"pk-drawer\">\n <div class=\"pk-dh\">\n <div class=\"pk-av\" id=\"pk-av\">\uD83E\uDD16</div>\n <div><div class=\"nm\" id=\"pk-name\">robot</div><div class=\"sub\" id=\"pk-sub\">satellite</div></div>\n <span class=\"pk-pill idle\" id=\"pk-state\" style=\"margin-left:0.4rem\">idle</span>\n <button class=\"x\" id=\"pk-close\">\u2715</button>\n </div>\n <div class=\"pk-dbody\" id=\"pk-body\"></div>\n </div>\n</div>\n\n<div class=\"pane\" id=\"pane-robopark\">\n <div class=\"rp-head\">\n <h2 style=\"margin:0\">RoboPark \u00B7 sessions & telemetry</h2>\n <div class=\"rp-controls\">\n <span class=\"pm\" id=\"pm-wrap\" hidden>\n <span>production mode</span>\n <button class=\"toggle\" id=\"pm-toggle\" role=\"switch\" aria-checked=\"false\" title=\"master enable \u2014 robots only join sessions when ON\"></button>\n <span class=\"state off\" id=\"pm-state\">off</span>\n </span>\n <div class=\"rp-totals\" id=\"rp-totals\"></div>\n </div>\n </div>\n <div class=\"rp-grid\" id=\"rp-grid\"></div>\n</div>\n\n<footer class=\"cmd\">\n <span class=\"sel\">on <b id=\"selName\">this node</b></span>\n <input id=\"cmdInput\" placeholder=\"/status /nodes /health (Enter to run)\" autocomplete=\"off\" spellcheck=\"false\">\n <button class=\"act\" id=\"runBtn\">Run</button>\n <button class=\"act ghost\" id=\"applyBtn\">Apply config to fleet</button>\n</footer>\n<div id=\"out\"><button class=\"x\" id=\"outClose\">close</button><span id=\"outText\"></span></div>\n\n<script>\n(function(){\n var TOKEN = new URLSearchParams(location.search).get('token') || '';\n var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n var lastStatus = null; // most recent /fed/status snapshot (shared with Park)\n var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)\n\n var $ = function(id){ return document.getElementById(id); };\n function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }\n function fmtBytes(b){ if(!b) return '0'; var u=['B','KB','MB','GB','TB']; var i=Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i?1:0)+u[i]; }\n function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }\n function clock(ts){ var d=new Date(ts); function p(n){return (n<10?'0':'')+n;} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }\n\n // theme (dark glass is the default; toggle to frosted light)\n (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}\n document.documentElement.setAttribute('data-theme', s || 'dark');\n $('theme').addEventListener('click', function(){\n var cur=document.documentElement.getAttribute('data-theme')||'dark';\n var nx=cur==='dark'?'light':'dark';\n document.documentElement.setAttribute('data-theme', nx);\n try{localStorage.setItem(k,nx)}catch(e){}\n });\n })();\n\n function detailOf(data){\n var p = data || {};\n if(typeof p==='string') return p;\n if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' \u2713':' \u2717'):'');\n if(p.model) return String(p.model);\n if(p.provider) return String(p.provider);\n if(p.reason) return String(p.reason).slice(0,60);\n if(p.description) return String(p.description).slice(0,50);\n if(p.name) return String(p.name);\n return '';\n }\n function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }\n\n function summarizeFrame(f){\n var d = f.d || {};\n if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% \u00B7 mem '+m+'%'; }\n if(f.t==='hb'){ return 'heartbeat \u00B7 load '+(d.load!=null?d.load:'-'); }\n if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }\n if(f.t==='rt'){ var rp=d.d||{}; return 'route \u2192 '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }\n if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }\n if(f.t==='log'){\n if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);\n return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' \u00D7'+d.count):''); }\n return 'log';\n }\n return f.t;\n }\n\n function renderFeed(activity){\n var feed = $('feed');\n if(!activity || !activity.length){\n if(!feed.dataset.seeded){ feed.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">waiting for fleet activity \u2014 task runs, routing, and peer events stream here.</span></div>'; }\n return;\n }\n feed.dataset.seeded = '1';\n var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;\n var html = '';\n for(var i=0;i<activity.length;i++){\n var e = activity[i]; var f = e.f || e;\n var name = (f.n||'').slice(0,10);\n html += '<div class=\"line k-'+esc(f.t)+'\"><span class=\"t\">'+clock(f.ts)+'</span>'\n + '<span class=\"n\" title=\"'+esc(f.n)+'\">'+esc(name)+'</span>'\n + '<span class=\"d\">'+esc(summarizeFrame(f))+'</span></div>';\n }\n feed.innerHTML = html;\n if(near) feed.scrollTop = feed.scrollHeight;\n }\n\n function meter(label, value, sub){\n var v = Math.max(0, Math.min(100, Math.round(value||0)));\n return '<div class=\"meter\"><div class=\"lab\"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'\n + (sub?(' '+esc(sub)):'')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div></div>';\n }\n\n function renderHw(hw){\n var el = $('hw');\n if(!hw){ el.innerHTML = '<div class=\"meter\"><div class=\"lab\"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }\n var html = '<h2 style=\"margin-top:0\">This node \u00B7 '+esc(hw.system?hw.system.hostname:'')+'</h2>';\n html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');\n html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');\n if(hw.gpu){ html += meter('GPU '+esc(hw.gpu.name||hw.gpu.vendor||''), hw.gpu.utilizationPercent, hw.gpu.vramTotalBytes?(fmtBytes(hw.gpu.vramUsedBytes)+' / '+fmtBytes(hw.gpu.vramTotalBytes)+' vram'):''); }\n el.innerHTML = html;\n }\n\n function selectNode(id, name){\n selected = (id===selfId)?null:id;\n $('selName').textContent = selected ? name : 'this node';\n var cards = document.querySelectorAll('.node');\n for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);\n }\n\n function renderFleet(nodes){\n var el = $('fleet');\n if(!nodes || !nodes.length){ el.innerHTML = '<div class=\"empty\">No nodes reporting yet. Start peers with <span class=\"mono\">infinicode serve</span> or <span class=\"mono\">robopark setup</span>.</div>'; return; }\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var html = '';\n for(var i=0;i<nodes.length;i++){\n var n = nodes[i];\n var isSelf = n.nodeId===selfId;\n var load = n.load!=null?n.load:0;\n var caps = (n.capabilities||[]).slice(0,6);\n var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class=\"cap\">'+esc(caps[c])+'</span>';\n var site = n.site?('\u00B7 '+esc(n.site)):'';\n html += '<div class=\"node '+(n.connected?'up':'down')+(selected===n.nodeId?' sel':'')+'\" tabindex=\"0\" role=\"button\" data-id=\"'+esc(n.nodeId)+'\" data-name=\"'+esc(n.displayName)+'\">'\n + '<div class=\"top\"><span class=\"name\">'+esc(n.displayName)+(isSelf?' <span class=\"selfbadge\">this</span>':'')+'</span><span class=\"role\">'+esc(n.role)+'</span></div>'\n + '<div class=\"meta\">'+esc(n.platform||'')+' '+site+' <span class=\"mono\">v'+esc(n.version||'?')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(load)+'\" style=\"width:'+(n.connected?load:0)+'%\"></div></div>'\n + '<div class=\"load\"><span>'+(n.connected?'load '+Math.round(load)+'%':'offline')+'</span><span>'+((n.commands||[]).length)+' cmds</span></div>'\n + (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'')\n + '</div>';\n }\n el.innerHTML = html;\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }\n card.addEventListener('click', pick);\n card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });\n })(cards[j]);\n }\n }\n\n function renderSummary(s){\n var nodes = s.nodes||[];\n var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;\n var down = nodes.length-up;\n var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;\n var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});\n var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;\n $('summary').innerHTML =\n '<span class=\"chip ok\">nodes up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">active runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>';\n $('fleetcount').textContent = '(' + nodes.length + ')';\n var self = s.self||{};\n var role = s.role||{};\n $('who').textContent = (self.displayName||'node') + ' \u00B7 ' + (self.role||'?') + (role.site?(' \u00B7 '+role.site):'') + ' \u00B7 v' + (self.version||'?');\n }\n\n function setLive(ok){\n $('livedot').classList.toggle('stale', !ok);\n $('offline').style.display = (!ok && everOk) ? 'block' : 'none';\n }\n\n function poll(){\n fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){\n if(!r.ok) throw new Error('status '+r.status);\n return r.json();\n }).then(function(s){\n everOk = true; setLive(true); lastStatus = s;\n if(!selfId && s.self) selfId = s.self.nodeId;\n if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }\n renderSummary(s);\n renderFleet(s.nodes||[]);\n renderHw(s.hardware);\n renderFeed(s.activity||[]);\n Park.onStatus(s);\n }).catch(function(){ setLive(false); });\n }\n\n function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }\n $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });\n\n function runCommand(input){\n input = (input||'').trim();\n if(!input) return;\n var payload = selected ? ('/node ' + selected + ' ' + input) : input;\n $('runBtn').disabled = true;\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })\n .catch(function(e){ showOut('request failed: '+e.message); })\n .then(function(){ $('runBtn').disabled=false; });\n }\n\n $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });\n $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });\n $('applyBtn').addEventListener('click', function(){\n $('applyBtn').disabled = true;\n fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })\n .catch(function(e){ showOut('apply failed: '+e.message); })\n .then(function(){ $('applyBtn').disabled=false; });\n });\n\n // \u2500\u2500 tabs: Fleet + Park + RoboPark sessions/telemetry \u2500\u2500\n var tabs = document.querySelectorAll('.tab');\n var activeTab = 'fleet';\n for (var ti=0; ti<tabs.length; ti++){\n (function(btn){\n btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });\n })(tabs[ti]);\n }\n function selectTab(name){\n activeTab = name;\n for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);\n $('pane-fleet').classList.toggle('active', name==='fleet');\n $('pane-park').classList.toggle('active', name==='park');\n $('pane-robopark').classList.toggle('active', name==='robopark');\n if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark') pollRobopark();\n if (name==='park') Park.activate(); else Park.deactivate();\n }\n var initHash = location.hash.replace('#','');\n if (initHash==='robopark'||initHash==='park') selectTab(initHash);\n\n function robotsFrom(d){\n if (Array.isArray(d)) return d;\n if (d && Array.isArray(d.robots)) return d.robots;\n return [];\n }\n function applyReadOnly(){\n var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;\n var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;\n var note=$('rp-ro-note');\n if(rpReadOnly && !note){\n note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';\n note.textContent='read-only (no token) \u2014 start the hub with --token to enable control';\n var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);\n } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }\n }\n\n function rpAction(method, path, body){\n if(rpReadOnly){ showOut('This node is read-only \u2014 restart the hub with --token to enable control actions.'); return Promise.resolve(null); }\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: body?{'content-type':'application/json'}:undefined,\n body: body?JSON.stringify(body):undefined\n }).then(function(r){\n if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }\n return r.json().catch(function(){ return {}; }).then(function(j){ if(!r.ok) throw new Error((j&&(j.error||j.detail))||('status '+r.status)); return j; });\n });\n }\n\n function setPm(on){\n var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');\n if(!wrap) return;\n wrap.hidden=false;\n tg.setAttribute('aria-checked', on?'true':'false');\n st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');\n }\n (function(){\n var tg=$('pm-toggle'); if(!tg) return;\n tg.addEventListener('click', function(){\n if(rpReadOnly||pmBusy) return;\n var next = tg.getAttribute('aria-checked')!=='true';\n pmBusy=true; tg.disabled=true;\n rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){\n setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);\n }).catch(function(e){ showOut('production_mode: '+e.message); })\n .then(function(){ pmBusy=false; applyReadOnly(); });\n });\n })();\n function pollSettings(){\n fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){\n if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);\n }).catch(function(){});\n }\n\n function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }\n function endSession(id){\n if(!confirm('End the active session for '+id+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();\n }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });\n }\n $('rp-grid').addEventListener('click', function(e){\n var b=e.target.closest?e.target.closest('.rp-btn'):null; if(!b) return;\n var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');\n if(act==='end') endSession(id);\n });\n\n function pollRobopark(){\n pollSettings();\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){\n var grid = $('rp-grid'), totals = $('rp-totals');\n // stash per-robot rows so the Park drawer can show live session stats\n roboparkByName = {};\n robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; });\n if (d && (d.disabled || d.ok===false)){\n totals.innerHTML=''; $('pm-wrap').hidden=true;\n grid.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1\">'+esc(d.error||'no scheduler linked')+' \u2014 start the hub with <span class=\"mono\">--scheduler-url http://<scheduler>:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n var tot = (d && d.totals) || {};\n var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);\n var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);\n totals.innerHTML =\n '<span class=\"chip\">robots <b>'+robots.length+'</b></span>'\n + '<span class=\"chip\">sessions <b>'+totalSessions+'</b></span>'\n + '<span class=\"chip\">triggers <b>'+totalTrig+'</b></span>';\n if (!robots.length){ grid.innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">No robot telemetry yet \u2014 it populates as robots trigger sessions.</div>'; return; }\n var html='';\n for (var i=0;i<robots.length;i++){\n var x = robots[i];\n var name = robotName(x);\n var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014';\n var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';\n var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'\u2014';\n var active = x.active_session||x.in_session||x.session_active;\n var idAttr = esc(name);\n html += '<div class=\"rp-card\"><h4>'+esc(name)+(active?' <span class=\"chip\" style=\"color:var(--ok)\">live</span>':'')+'</h4><div class=\"rp-metrics\">'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+lat+'</span><span class=\"l\">avg latency</span></div>'\n + '<div class=\"rp-m\"><span class=\"v '+dcls+'\">'+drop+'</span><span class=\"l\">drop rate</span></div>'\n + '</div><div class=\"rp-actions\">'\n + '<button class=\"rp-btn danger\" data-act=\"end\" data-robot=\"'+idAttr+'\"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'\n + '</div></div>';\n }\n grid.innerHTML = html;\n }).catch(function(){\n $('rp-grid').innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">telemetry unreachable</div>';\n });\n }\n\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n // Park \u2014 isometric live map of the mesh\n // Real data: /fed/status nodes (placement + load) + /fed/stream frames\n // (agent movement + tool-use pulses) + /robopark telemetry (session stats).\n // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.\n // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n var Park = (function(){\n var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1};\n var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;\n var TW=124, TH=62;\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;\n var RING=[[2,0],[1,2],[-1,2],[-2,0],[-1,-2],[1,-2],[3,1],[3,-1],[0,3],[-3,1],[-3,-1],[0,-3],[2,3],[-2,3],[2,-3],[-2,-3],[4,0],[0,4],[-4,0],[0,-4]];\n\n function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }\n function kindOf(n){\n var r=(n.role||'').toLowerCase();\n if(r==='hub'||r==='scheduler') return 'hub';\n if(r==='satellite'||r==='robot') return 'robot';\n var caps=(n.capabilities||[]).join(' ');\n if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';\n return 'peer';\n }\n function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }\n function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }\n\n function layout(src){\n var list=(src||[]).slice().sort(function(a,b){ return (a.nodeId<b.nodeId?-1:1); });\n var hub=null; for(var i=0;i<list.length;i++){ if(kindOf(list[i])==='hub'){ hub=list[i]; break; } }\n if(!hub && list.length) hub=list[0];\n var out=[], oi=0;\n list.forEach(function(n){\n var cell = (n===hub)? [0,0] : RING[(oi++)%RING.length];\n var prev = byId[n.nodeId];\n out.push({\n id:n.nodeId, name:n.displayName||n.nodeId, role:n.role||'?', kind:kindOf(n),\n connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',\n site:n.site||'', caps:n.capabilities||[], gx:cell[0], gy:cell[1], _hit:prev?prev._hit:null\n });\n });\n return out;\n }\n\n function onStatus(s){\n if(!s) return;\n nodes = layout(s.nodes||[]);\n byId={}; nodes.forEach(function(n){ byId[n.id]=n; });\n // if not streaming, animate from the polled activity tail\n if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }\n }\n\n function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }\n function findByFrame(f){\n var key=f.n; if(key==null) return null;\n for(var i=0;i<nodes.length;i++){ if(nodes[i].name===key||nodes[i].id===key) return nodes[i]; }\n return null;\n }\n function ingest(f, fromPoll){\n if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }\n var n=findByFrame(f), h=hub();\n if(n && h){\n if(n.kind==='hub'){ pulse(n, glyphFor(f)); }\n else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }\n else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }\n }\n feedRow(f);\n }\n function glyphFor(f){\n if(f.t==='rt') return '\u21C4';\n if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '\u2328'; if(/session|presence/.test(k)) return '\uD83C\uDF99'; return '\u2726'; }\n if(f.t==='hw'||f.t==='hb') return '';\n return '\u2726';\n }\n function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }\n function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'\u2726'}); }\n\n function feedRow(f){\n var rows=$('pk-rows'); if(!rows) return;\n var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;\n var r=document.createElement('div'); r.className='pk-row';\n r.innerHTML='<span class=\"k pk-k-'+esc(kind)+'\"></span><span class=\"t\">'+clock(f.ts)+'</span>'\n +'<span class=\"who\" title=\"'+esc(f.n)+'\">'+esc((f.n||'').slice(0,10))+'</span>'\n +'<span class=\"msg\">'+esc(summarizeFrame(f))+'</span>';\n rows.insertBefore(r, rows.firstChild);\n while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);\n }\n\n // \u2500\u2500 canvas + projection \u2500\u2500\n function ensure(){\n if(C) return;\n C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();\n DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));\n var d=false,px,py;\n C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});\n window.addEventListener('mouseup',function(){d=false;});\n window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });\n C.addEventListener('wheel',function(e){ e.preventDefault(); var f=e.deltaY<0?1.08:0.925; cam.z=Math.max(.5,Math.min(2.4,cam.z*f)); },{passive:false});\n C.addEventListener('click',function(e){\n var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;\n for(var i=0;i<nodes.length;i++){ var hb=nodes[i]._hit; if(hb&&mx>=hb.x&&mx<=hb.x+hb.w&&my>=hb.y&&my<=hb.y+hb.h) hit=nodes[i]; }\n if(hit){ openDrawer(hit.id); selectNode(hit.id, hit.name); }\n });\n window.addEventListener('resize', function(){ if(running) resize(); });\n }\n function resize(){\n var st=$('pk-stage'); if(!st) return;\n W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);\n }\n function iso(gx,gy){ return { x: W/2 + cam.x + (gx-gy)*TW/2*cam.z, y: H*0.42 + cam.y + (gx+gy)*TH/2*cam.z }; }\n\n function tile(gx,gy,raised){\n var p=iso(gx,gy), z=cam.z, hw=TW/2*z, hh=TH/2*z, dz=raised?13*z:0;\n X.beginPath(); X.moveTo(p.x,p.y-hh-dz); X.lineTo(p.x+hw,p.y-dz); X.lineTo(p.x,p.y+hh-dz); X.lineTo(p.x-hw,p.y-dz); X.closePath();\n var g=X.createLinearGradient(p.x-hw,p.y-hh,p.x+hw,p.y+hh);\n g.addColorStop(0,raised?'rgba(60,52,86,0.55)':'rgba(30,26,44,0.32)'); g.addColorStop(1,raised?'rgba(20,17,32,0.6)':'rgba(16,14,26,0.28)');\n X.fillStyle=g; X.fill();\n X.strokeStyle=raised?'rgba(255,214,112,0.55)':'rgba(255,255,255,0.08)'; X.lineWidth=1; X.stroke();\n if(raised){\n X.beginPath(); X.moveTo(p.x-hw,p.y-dz); X.lineTo(p.x-hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();\n X.fillStyle='rgba(10,8,18,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.28)'; X.stroke();\n X.beginPath(); X.moveTo(p.x+hw,p.y-dz); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();\n X.fillStyle='rgba(14,11,24,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.2)'; X.stroke();\n }\n return p;\n }\n function ring(cx,cy,r,frac,color){\n X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.12)'; X.lineWidth=3*cam.z; X.stroke();\n X.beginPath(); X.arc(cx,cy,r,-Math.PI/2,-Math.PI/2+Math.PI*2*Math.max(0,Math.min(1,frac))); X.strokeStyle=color; X.lineWidth=3*cam.z; X.stroke();\n }\n function label(cx,cy,txt,col,sub){\n var z=cam.z; X.textAlign='center';\n X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);\n X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);\n }\n function drawHub(n){\n var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-14*z-bob;\n X.fillStyle='rgba(40,34,58,0.9)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;\n X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();\n for(var i=0;i<4;i++){ X.fillStyle=i%2?getVar('--ok'):getVar('--gold-bright'); X.globalAlpha=0.4+0.6*Math.abs(Math.sin((performance.now()-t0)/300+i)); X.fillRect(bx-11*z,by-34*z+i*9*z,22*z,4*z); X.globalAlpha=1; }\n X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();\n X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();\n label(bx,p.y+18*z,n.name,getVar('--gold-bright'), (n.connected?'HUB \u00B7 '+Math.round(n.load)+'%':'offline'));\n n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};\n }\n function drawRobot(n){\n var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/500+n.gx)*2.4*z, bx=p.x, by=p.y-10*z-bob;\n ring(bx,p.y+2*z,20*z,n.load/100,loadColor(n.load));\n X.fillStyle='rgba(0,0,0,0.35)'; X.beginPath(); X.ellipse(bx,p.y+2*z,15*z,7*z,0,0,7); X.fill();\n var col=n.connected?getVar('--gold-bright'):'rgba(150,140,120,0.5)';\n X.fillStyle='rgba(24,20,38,0.9)'; X.strokeStyle=col; X.lineWidth=1.6;\n X.beginPath(); X.roundRect(bx-12*z,by-24*z,24*z,26*z,5*z); X.fill(); X.stroke();\n X.beginPath(); X.roundRect(bx-9*z,by-38*z,18*z,15*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();\n X.fillStyle=n.connected?getVar('--ok'):'#654'; X.beginPath(); X.arc(bx-4*z,by-30*z,2.1*z,0,7); X.arc(bx+4*z,by-30*z,2.1*z,0,7); X.fill();\n X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-38*z); X.lineTo(bx,by-45*z); X.stroke();\n X.fillStyle=n.connected?'#FF5B4A':'#654'; X.beginPath(); X.arc(bx,by-46*z,2.2*z,0,7); X.fill();\n label(bx,p.y+18*z,n.name,col,(n.connected?'load '+Math.round(n.load)+'%':'offline'));\n n._hit={x:bx-16*z,y:by-48*z,w:32*z,h:62*z};\n }\n function drawPeer(n){\n var p=tile(n.gx,n.gy,true), z=cam.z, bx=p.x, by=p.y-8*z;\n var col=n.connected?getVar('--info'):'rgba(150,140,120,0.5)';\n X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;\n X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();\n X.fillStyle=col; X.globalAlpha=0.5+0.5*Math.abs(Math.sin((performance.now()-t0)/500)); X.fillRect(bx-8*z,by-14*z,16*z,3*z); X.globalAlpha=1;\n label(bx,p.y+16*z,n.name,col,(n.connected?n.role:'offline'));\n n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};\n }\n\n function frame(){\n if(!running) return;\n X.clearRect(0,0,W,H);\n for(var gx=-4;gx<=4;gx++) for(var gy=-4;gy<=4;gy++){ if(!nodes.some(function(n){return n.gx===gx&&n.gy===gy;})) faint(gx,gy); }\n var sorted=nodes.slice().sort(function(a,b){ return (a.gx+a.gy)-(b.gx+b.gy); });\n sorted.forEach(function(n){ if(n.kind==='hub') drawHub(n); else if(n.kind==='robot') drawRobot(n); else drawPeer(n); });\n for(var i=orbs.length-1;i>=0;i--){ var o=orbs[i]; o.t+=0.024; if(o.t>=1){orbs.splice(i,1);continue;}\n var a=iso(o.a.gx,o.a.gy), b=iso(o.b.gx,o.b.gy), e=o.t<.5?2*o.t*o.t:-1+(4-2*o.t)*o.t;\n var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o.t*Math.PI)*40*cam.z;\n X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o.color; X.shadowColor=o.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;\n }\n for(var j=pulses.length-1;j>=0;j--){ var pu=pulses[j]; pu.t+=0.02; if(pu.t>=1){pulses.splice(j,1);continue;}\n var pp=iso(pu.n.gx,pu.n.gy), yy=pp.y-58*cam.z-pu.t*24*cam.z;\n X.globalAlpha=1-pu.t; X.font='700 '+(15*cam.z)+'px '+mono(); X.textAlign='center'; X.fillStyle=getVar('--gold-bright'); X.fillText(pu.g,pp.x,yy);\n X.beginPath(); X.arc(pp.x,pp.y-20*cam.z,(10+pu.t*22)*cam.z,0,7); X.strokeStyle='rgba(255,214,112,'+(1-pu.t)+')'; X.lineWidth=2; X.stroke(); X.globalAlpha=1;\n }\n raf=requestAnimationFrame(frame);\n }\n function faint(gx,gy){ var p=iso(gx,gy),z=cam.z,hw=TW/2*z,hh=TH/2*z;\n X.beginPath(); X.moveTo(p.x,p.y-hh); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x-hw,p.y); X.closePath();\n X.strokeStyle='rgba(255,255,255,0.045)'; X.lineWidth=1; X.stroke();\n }\n\n function connectStream(){\n if(es) return;\n try{\n es=new EventSource('/fed/stream'+QS);\n es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };\n es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream \u00B7 live'; };\n es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status \u00B7 poll'; };\n }catch(e){ es=null; }\n }\n\n function activate(){\n ensure();\n if(lastStatus) onStatus(lastStatus);\n resize();\n connectStream();\n if(!running){ running=true; raf=requestAnimationFrame(frame); }\n }\n function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }\n\n // \u2500\u2500 robot drawer \u2500\u2500\n function sect(title, inner){ return '<div class=\"pk-sect\"><h4>'+title+'</h4>'+inner+'</div>'; }\n function openDrawer(id){\n var n=byId[id]; if(!n) return; drawerId=id;\n $('pk-drawer').classList.add('open');\n $('pk-av').textContent = n.kind==='hub'?'\uD83D\uDDA5':(n.kind==='robot'?'\uD83E\uDD16':'\uD83E\uDDE9');\n $('pk-name').textContent=n.name;\n $('pk-sub').textContent=n.role+' \u00B7 '+(n.platform||'?')+' \u00B7 v'+n.version+(n.site?(' \u00B7 '+n.site):'');\n var st=$('pk-state'); st.textContent=n.connected?'live':'idle'; st.className='pk-pill '+(n.connected?'live':'idle');\n buildDrawer(n);\n pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick\n if(drawerTimer) clearInterval(drawerTimer);\n drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);\n }\n function closeDrawer(){\n $('pk-drawer').classList.remove('open'); drawerId=null;\n if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;\n if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;\n if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }\n if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }\n }\n function pollRoboparkQuiet(){\n fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){\n var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};\n list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; });\n refreshDrawer();\n }).catch(function(){});\n }\n function buildDrawer(n){\n var b=$('pk-body');\n if(n.kind==='robot'){\n b.innerHTML =\n sect('\uD83D\uDC41 camera feed',\n '<div class=\"pk-cam\" id=\"pk-cam\"><canvas id=\"pk-camcv\" width=\"384\" height=\"216\"></canvas>'\n +'<div class=\"hud\"><div class=\"rec\"><i></i>REC \u00B7 '+esc(n.name)+'</div><div class=\"tr\" id=\"pk-camres\">\u2014 \u00B7 \u2014</div><div class=\"br\" id=\"pk-camts\">--:--:--</div></div></div>'\n +'<div class=\"pk-note\" id=\"pk-camnote\">seeking LiveKit track\u2026</div>')\n + sect('\uD83C\uDF99 microphone','<div class=\"pk-meter\"><span class=\"lab\">input</span><div class=\"pk-bars\" id=\"pk-micbars\"></div><span class=\"pk-val\" id=\"pk-micval\">\u2014</span></div><div class=\"pk-note\" id=\"pk-micstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDD0A speaker','<canvas class=\"pk-wave\" id=\"pk-spkwave\" width=\"360\" height=\"32\"></canvas><div class=\"pk-meter\"><span class=\"lab\">output</span><div class=\"pk-bars\" id=\"pk-spkbars\"></div><span class=\"pk-val\" id=\"pk-spkval\">\u2014</span></div><div class=\"pk-note\" id=\"pk-spkstate\">awaiting robot audio plugin</div>')\n + sect('\uD83D\uDCCA hardware','<div class=\"pk-hwgrid\"><div><div class=\"l\">load</div><div class=\"track\"><div class=\"fill\" id=\"pk-hw-load\"></div></div></div><div><div class=\"l\">status</div><div class=\"track\"><div class=\"fill\" id=\"pk-hw-conn\" style=\"background:'+(n.connected?'var(--ok)':'var(--muted)')+'\"></div></div></div></div><div class=\"pk-note\">remote node \u2014 full CPU/mem telemetry shows on the node itself</div>')\n + sect('\uD83D\uDDD3 session telemetry','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-trig\">\u2014</div><div class=\"l\">triggers</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-sess\">\u2014</div><div class=\"l\">sessions</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-lat\">\u2014</div><div class=\"l\">avg latency</div></div><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-drop\">\u2014</div><div class=\"l\">drop rate</div></div></div>')\n + sect('\u2699 controls','<div class=\"pk-ctl\"><button class=\"rp-btn danger\" id=\"pk-end\" data-robot=\"'+esc(n.name)+'\">End session</button><button class=\"rp-btn\" id=\"pk-follow\">Follow run</button></div><div class=\"pk-note\">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>');\n startCam(n); startMeters(n);\n var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(n.name); });\n var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });\n } else {\n b.innerHTML =\n sect('\uD83D\uDDA5 node','<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\" id=\"pk-tv-load\">\u2014</div><div class=\"l\">load</div></div><div class=\"pk-kv\"><div class=\"v '+(n.connected?'ok':'')+'\">'+(n.connected?'up':'down')+'</div><div class=\"l\">status</div></div></div>')\n + sect('\uD83D\uDD0C capabilities', (n.caps.length?('<div class=\"caps\">'+n.caps.map(function(c){return '<span class=\"cap\">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class=\"pk-note\">none reported</div>'))\n + sect('\u2699 controls','<div class=\"pk-ctl\"><button class=\"rp-btn\" id=\"pk-status\">Run /status</button></div>');\n var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });\n }\n refreshDrawer();\n }\n function refreshDrawer(){\n var n=drawerId?byId[drawerId]:null; if(!n) return;\n setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);\n var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';\n var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';\n var row=roboparkByName[n.name];\n if(row){\n set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);\n set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);\n set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'\u2014');\n var dEl=$('pk-tv-drop'); if(dEl){ var d=row.drop_rate!=null?Math.round(row.drop_rate*100):null; dEl.textContent=d!=null?d+'%':'\u2014'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }\n var active=row.active_session||row.in_session||row.session_active;\n var st=$('pk-state'); if(st){ st.textContent=active?'in session':(n.connected?'live':'idle'); st.className='pk-pill '+(active?'live':(n.connected?'live':'idle')); }\n }\n }\n function set(id,v){ var e=$(id); if(e) e.textContent=v; }\n function setFill(id,pct){ var e=$(id); if(!e||pct==null) return; e.style.width=Math.max(2,Math.min(100,pct))+'%'; e.style.background=pct>=75?'var(--bad)':pct>=45?'var(--warn)':'var(--gold-bright)'; }\n\n // \u2500\u2500 cam: target a LiveKit track; fall back to a styled preview \u2500\u2500\n function startCam(n){\n var note=$('pk-camnote'), res=$('pk-camres');\n camAbort = ('AbortController' in window)?new AbortController():null;\n // The robot-side RoboPark plugin exposes this; returns {url, token, room, trackSid}.\n fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})\n .then(function(r){ if(!r.ok) throw new Error('no stream'); return r.json(); })\n .then(function(info){\n if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); }\n else if(info && info.url){ if(note) note.textContent='LiveKit track ready \u2014 load livekit-client to view'; previewCam(n); }\n else throw new Error('no track');\n })\n .catch(function(){ if(note) note.textContent='no published cam track \u2014 showing preview (robot plugin pending)'; if(res) res.textContent='preview'; previewCam(n); });\n }\n function attachLiveKit(info, n){\n var mount=$('pk-cam'), note=$('pk-camnote'), res=$('pk-camres'); if(!mount) return;\n try{\n var LK=window.LivekitClient, room=new LK.Room();\n room.connect(info.url, info.token).then(function(){\n if(note) note.textContent='LiveKit \u00B7 connected';\n room.on(LK.RoomEvent.TrackSubscribed, function(track){\n if(track.kind==='video'){ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true;\n var cv=$('pk-camcv'); if(cv&&cv.parentNode){ cv.parentNode.replaceChild(el, cv); } if(res) res.textContent='LiveKit \u00B7 live'; }\n });\n }).catch(function(){ if(note) note.textContent='LiveKit connect failed \u2014 preview'; previewCam(n); });\n }catch(e){ if(note) note.textContent='LiveKit error \u2014 preview'; previewCam(n); }\n }\n function previewCam(n){\n var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();\n var res=$('pk-camres'); if(res && res.textContent==='\u2014 \u00B7 \u2014') res.textContent='preview \u00B7 24fps';\n (function loop(){ if(!$('pk-camcv')||drawerId==null) return;\n var tt=(performance.now()-s0)/1000;\n var g=cx.createLinearGradient(0,0,0,h); g.addColorStop(0,'#0a1420'); g.addColorStop(1,'#132233'); cx.fillStyle=g; cx.fillRect(0,0,w,h);\n cx.fillStyle='#0e1a12'; cx.beginPath(); cx.moveTo(0,h*0.62); cx.lineTo(w,h*0.62); cx.lineTo(w,h); cx.lineTo(0,h); cx.fill();\n var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;\n cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();\n cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);\n cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);\n cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);\n cx.globalAlpha=0.06; for(var y=0;y<h;y+=3){ cx.fillStyle=y%2?'#fff':'#000'; cx.fillRect(0,y,w,1); } cx.globalAlpha=1;\n for(var k=0;k<30;k++){ cx.fillStyle='rgba(255,255,255,'+(Math.random()*0.05)+')'; cx.fillRect(Math.random()*w,Math.random()*h,1,1); }\n var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());\n camRAF=requestAnimationFrame(loop);\n })();\n }\n function bars(el,n){ el.innerHTML=''; for(var i=0;i<n;i++){ var b=document.createElement('i'); el.appendChild(b); } return el.querySelectorAll('i'); }\n function startMeters(n){\n var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;\n var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw.getContext('2d'), s0=performance.now();\n // Preview envelopes until the robot audio plugin streams real levels.\n (function loop(){ if(!$('pk-micbars')||drawerId==null) return;\n var tt=(performance.now()-s0)/1000;\n var env=Math.max(0,Math.sin(tt*2.3))*Math.max(0,Math.sin(tt*0.7));\n for(var i=0;i<mi.length;i++){ var v=env*(0.4+0.6*Math.random())*(i<env*mi.length?1:0.25); mi[i].style.height=(3+v*13)+'px'; mi[i].style.background=i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'; }\n set('pk-micval', Math.round(env*100)+'%'); set('pk-micstate', env>0.3?'preview \u00B7 speech':'preview \u00B7 quiet');\n var spk=Math.max(0,Math.sin(tt*1.1-1)), speaking=spk>0.35;\n for(var j=0;j<si.length;j++){ var vv=speaking?(0.3+0.7*Math.random()):0.05; si[j].style.height=(3+vv*13)+'px'; si[j].style.background=speaking?'var(--gold-bright)':'var(--glass-border)'; }\n set('pk-spkval', speaking?Math.round(spk*100)+'%':'idle'); set('pk-spkstate', speaking?'preview \u00B7 TTS':'preview \u00B7 idle');\n swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle=speaking?getVar('--gold-bright'):'rgba(255,255,255,0.12)'; swx.lineWidth=1.5; swx.beginPath();\n for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.15+tt*8)*(speaking?spk*11:1.4)*Math.sin(x*0.03); if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke();\n meterRAF=requestAnimationFrame(loop);\n })();\n }\n\n $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);\n return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer };\n })();\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n})();\n</script>\n</body>\n</html>";
|
|
@@ -7,6 +7,18 @@
|
|
|
7
7
|
* POST /fed/command and POST /fed/apply. The browser authenticates every request
|
|
8
8
|
* with the ?token= it was loaded with (browsers can't set Bearer on navigation).
|
|
9
9
|
*
|
|
10
|
+
* Three tabs:
|
|
11
|
+
* Fleet — node cards + this-node hardware + live activity feed
|
|
12
|
+
* Park — isometric live map of the mesh (nodes placed on a floor, agent
|
|
13
|
+
* movement + tool-use pulses streamed from /fed/stream; click a robot
|
|
14
|
+
* for its cam / mic / speaker / hardware drawer)
|
|
15
|
+
* Sessions & Telemetry — RoboPark scheduler stats + production_mode + controls
|
|
16
|
+
*
|
|
17
|
+
* The Park cam panel targets a LiveKit video track: it fetches a per-robot stream
|
|
18
|
+
* token from /robopark/api/robots/:id/stream and, when the livekit-client SDK and
|
|
19
|
+
* a published track are present, subscribes; otherwise it shows a styled preview.
|
|
20
|
+
* That token/track is produced by the robot-side RoboPark plugin.
|
|
21
|
+
*
|
|
10
22
|
* Kept free of backticks and ${...} so the whole page fits inside this TS
|
|
11
23
|
* template literal without escaping; the client JS uses string concatenation.
|
|
12
24
|
*/
|
|
@@ -17,53 +29,66 @@ export const DASHBOARD_HTML = `<!doctype html>
|
|
|
17
29
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
30
|
<title>RoboPark Control</title>
|
|
19
31
|
<style>
|
|
32
|
+
/* ── premium tech glass theme ── dark is the committed default; light is a frosted toggle ── */
|
|
20
33
|
:root{
|
|
21
|
-
--bg:#
|
|
22
|
-
--
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
--bg:#08070F;
|
|
35
|
+
--grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),
|
|
36
|
+
radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),
|
|
37
|
+
radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),
|
|
38
|
+
linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);
|
|
39
|
+
--panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;
|
|
40
|
+
--glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);
|
|
41
|
+
--border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);
|
|
42
|
+
--ink:#F2ECDC; --muted:#9E93B4;
|
|
43
|
+
--gold:#E6B23C; --gold-bright:#FFD86B;
|
|
44
|
+
--ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);
|
|
45
|
+
--bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);
|
|
46
|
+
--shadow: 0 10px 34px rgba(0,0,0,0.42);
|
|
25
47
|
--mono:ui-monospace,"Cascadia Code",Consolas,monospace;
|
|
26
48
|
--sans:system-ui,"Segoe UI",Roboto,sans-serif;
|
|
27
49
|
}
|
|
28
|
-
@media (prefers-color-scheme:dark){:root{
|
|
29
|
-
|
|
30
|
-
--ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;
|
|
31
|
-
--ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;
|
|
32
|
-
--info:#6FB6FF; --info-bg:#0F2231;
|
|
33
|
-
}}
|
|
34
|
-
:root[data-theme="dark"]{
|
|
35
|
-
--bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;
|
|
36
|
-
--ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;
|
|
37
|
-
--ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;
|
|
38
|
-
--info:#6FB6FF; --info-bg:#0F2231;
|
|
39
|
-
}
|
|
50
|
+
@media (prefers-color-scheme:dark){:root{}}
|
|
51
|
+
:root[data-theme="dark"]{}
|
|
40
52
|
:root[data-theme="light"]{
|
|
41
|
-
--bg:#
|
|
42
|
-
--
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
--bg:#EDEAF6;
|
|
54
|
+
--grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),
|
|
55
|
+
radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),
|
|
56
|
+
linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);
|
|
57
|
+
--panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;
|
|
58
|
+
--glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);
|
|
59
|
+
--border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);
|
|
60
|
+
--ink:#241A08; --muted:#6E5A3E;
|
|
61
|
+
--gold:#8A5A0C; --gold-bright:#B9781A;
|
|
62
|
+
--ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);
|
|
63
|
+
--bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);
|
|
64
|
+
--shadow: 0 10px 30px rgba(60,40,10,0.14);
|
|
45
65
|
}
|
|
46
66
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
47
67
|
html,body{height:100%}
|
|
48
|
-
body{background:var(--bg);
|
|
68
|
+
body{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;
|
|
69
|
+
color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}
|
|
49
70
|
.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}
|
|
71
|
+
.glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);
|
|
72
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
50
73
|
|
|
51
74
|
/* top bar */
|
|
52
75
|
header.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;
|
|
53
|
-
padding:0.7rem 1.1rem;border-bottom:1px solid var(--border);
|
|
76
|
+
padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);
|
|
77
|
+
background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
|
|
78
|
+
position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}
|
|
54
79
|
.brand{display:flex;align-items:center;gap:0.6rem;}
|
|
55
|
-
.livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0
|
|
56
|
-
.livedot.stale{background:var(--muted);animation:none;}
|
|
57
|
-
@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}70%{box-shadow:0 0 0 7px transparent}100%{box-shadow:0 0 0 0 transparent}}
|
|
80
|
+
.livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 2.4s infinite;flex:none;}
|
|
81
|
+
.livedot.stale{background:var(--muted);box-shadow:none;animation:none;}
|
|
82
|
+
@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent),0 0 12px var(--ok)}70%{box-shadow:0 0 0 7px transparent,0 0 12px var(--ok)}100%{box-shadow:0 0 0 0 transparent,0 0 12px var(--ok)}}
|
|
58
83
|
@media (prefers-reduced-motion:reduce){.livedot{animation:none}}
|
|
59
84
|
.brand b{font-size:0.98rem;letter-spacing:-0.01em;}
|
|
60
85
|
.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}
|
|
61
86
|
.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}
|
|
62
|
-
.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--border);
|
|
87
|
+
.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);
|
|
63
88
|
background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}
|
|
64
89
|
.chip b{color:var(--ink);font-weight:600;}
|
|
65
90
|
.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}
|
|
66
|
-
.themebtn{font-family:var(--mono);font-size:0.68rem;background:
|
|
91
|
+
.themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);
|
|
67
92
|
border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}
|
|
68
93
|
.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
|
|
69
94
|
|
|
@@ -76,10 +101,11 @@ section h2 .count{color:var(--muted);}
|
|
|
76
101
|
|
|
77
102
|
/* fleet cards */
|
|
78
103
|
.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}
|
|
79
|
-
.node{
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.node
|
|
104
|
+
.node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;
|
|
105
|
+
background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
106
|
+
border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
107
|
+
.node:hover{transform:translateY(-2px);border-color:var(--border-strong);}
|
|
108
|
+
.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}
|
|
83
109
|
.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}
|
|
84
110
|
.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
|
|
85
111
|
.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}
|
|
@@ -88,16 +114,20 @@ section h2 .count{color:var(--muted);}
|
|
|
88
114
|
.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}
|
|
89
115
|
.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);
|
|
90
116
|
border-radius:4px;padding:0 0.3em;}
|
|
91
|
-
.bar-track{height:6px;border-radius:4px;background:
|
|
92
|
-
.bar-
|
|
93
|
-
.bar-fill
|
|
117
|
+
.bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}
|
|
118
|
+
:root[data-theme="light"] .bar-track{background:rgba(90,70,20,0.14);}
|
|
119
|
+
.bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;box-shadow:0 0 8px color-mix(in srgb,var(--ok) 60%,transparent);}
|
|
120
|
+
.bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}
|
|
121
|
+
.bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}
|
|
94
122
|
.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}
|
|
95
123
|
.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}
|
|
96
|
-
.cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border-radius:
|
|
97
|
-
.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:
|
|
124
|
+
.cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border:1px solid var(--glass-border);border-radius:5px;padding:0.05em 0.4em;}
|
|
125
|
+
.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}
|
|
98
126
|
|
|
99
127
|
/* hardware meters */
|
|
100
|
-
.hw{
|
|
128
|
+
.hw{border-radius:13px;padding:0.85rem;margin-top:1rem;
|
|
129
|
+
background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
130
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
101
131
|
.meter{margin-bottom:0.65rem;}
|
|
102
132
|
.meter:last-child{margin-bottom:0;}
|
|
103
133
|
.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}
|
|
@@ -105,8 +135,10 @@ section h2 .count{color:var(--muted);}
|
|
|
105
135
|
|
|
106
136
|
/* activity feed */
|
|
107
137
|
aside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}
|
|
108
|
-
.feed{
|
|
109
|
-
|
|
138
|
+
.feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;
|
|
139
|
+
background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
140
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
141
|
+
.line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}
|
|
110
142
|
.line:last-child{border-bottom:none;}
|
|
111
143
|
.line .t{color:var(--muted);flex:none;}
|
|
112
144
|
.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
@@ -114,34 +146,42 @@ aside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);di
|
|
|
114
146
|
.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}
|
|
115
147
|
|
|
116
148
|
/* command bar */
|
|
117
|
-
footer.cmd{position:fixed;left:0;right:0;bottom:0;
|
|
118
|
-
|
|
149
|
+
footer.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;
|
|
150
|
+
background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
|
|
151
|
+
border-top:1px solid var(--border-strong);padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;box-shadow:inset 0 1px 0 var(--glass-hi);}
|
|
119
152
|
footer .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}
|
|
120
153
|
footer .sel b{color:var(--ink);}
|
|
121
|
-
footer input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:
|
|
122
|
-
border:1px solid var(--border);border-radius:
|
|
154
|
+
footer input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);
|
|
155
|
+
border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}
|
|
156
|
+
:root[data-theme="light"] footer input{background:rgba(255,255,255,0.6);}
|
|
123
157
|
footer input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
|
|
124
|
-
button.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:
|
|
125
|
-
border:1px solid var(--border-strong);background:var(--gold-bright);color:#231704;white-space:nowrap;}
|
|
126
|
-
button.act.ghost{background:
|
|
158
|
+
button.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;
|
|
159
|
+
border:1px solid var(--border-strong);background:linear-gradient(180deg,var(--gold-bright),var(--gold));color:#231704;white-space:nowrap;box-shadow:0 4px 14px rgba(255,180,60,0.28);}
|
|
160
|
+
button.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}
|
|
127
161
|
button.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}
|
|
128
162
|
button.act:disabled{opacity:0.5;cursor:default;}
|
|
129
|
-
#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;
|
|
130
|
-
|
|
131
|
-
|
|
163
|
+
#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;
|
|
164
|
+
background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);
|
|
165
|
+
border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;
|
|
166
|
+
white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}
|
|
132
167
|
#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}
|
|
133
|
-
.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;
|
|
168
|
+
.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;
|
|
169
|
+
border-bottom:1px solid var(--glass-border);}
|
|
134
170
|
|
|
135
171
|
/* tabs + product panes */
|
|
136
|
-
.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--border);
|
|
172
|
+
.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);
|
|
173
|
+
background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
|
|
174
|
+
position:sticky;top:53px;z-index:19;}
|
|
137
175
|
.tab{font-family:var(--mono);font-size:0.74rem;padding:0.6rem 0.95rem;cursor:pointer;color:var(--muted);background:none;border:none;border-bottom:2px solid transparent;}
|
|
138
|
-
.tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);}
|
|
176
|
+
.tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);text-shadow:0 0 14px color-mix(in srgb,var(--gold-bright) 55%,transparent);}
|
|
139
177
|
.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}
|
|
140
178
|
.pane{display:none;} .pane.active{display:block;}
|
|
141
179
|
.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}
|
|
142
180
|
.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}
|
|
143
181
|
.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}
|
|
144
|
-
.rp-card{
|
|
182
|
+
.rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);
|
|
183
|
+
background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
184
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
145
185
|
.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}
|
|
146
186
|
.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}
|
|
147
187
|
.rp-m{display:flex;flex-direction:column;}
|
|
@@ -159,12 +199,82 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
159
199
|
.toggle:disabled{opacity:0.45;cursor:default;}
|
|
160
200
|
.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
|
|
161
201
|
.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}
|
|
162
|
-
.rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:
|
|
202
|
+
.rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:7px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}
|
|
163
203
|
.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}
|
|
164
204
|
.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}
|
|
165
205
|
.rp-btn:disabled{opacity:0.4;cursor:default;}
|
|
166
206
|
.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
|
|
167
207
|
.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}
|
|
208
|
+
|
|
209
|
+
/* ── Park (isometric live map) ── */
|
|
210
|
+
#pane-park{padding:0;}
|
|
211
|
+
.pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}
|
|
212
|
+
#pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}
|
|
213
|
+
#pk-canvas:active{cursor:grabbing;}
|
|
214
|
+
.pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;
|
|
215
|
+
display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);
|
|
216
|
+
border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
|
|
217
|
+
.pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);
|
|
218
|
+
padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}
|
|
219
|
+
.pk-feed .rows{overflow-y:auto;padding:0.25rem;}
|
|
220
|
+
.pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}
|
|
221
|
+
.pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}
|
|
222
|
+
.pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}
|
|
223
|
+
.pk-row .who{font-family:var(--mono);font-size:0.64rem;color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
224
|
+
.pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}
|
|
225
|
+
.pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}
|
|
226
|
+
.pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}
|
|
227
|
+
.pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}
|
|
228
|
+
.pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);
|
|
229
|
+
background:var(--panel);border:1px solid var(--glass-border);border-radius:9px;padding:0.45rem 0.65rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);}
|
|
230
|
+
.pk-hint b{color:var(--gold-bright);}
|
|
231
|
+
.pk-legend{position:absolute;left:12px;top:12px;z-index:6;display:flex;gap:0.5rem;flex-wrap:wrap;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
|
|
232
|
+
.pk-legend span{background:var(--panel);border:1px solid var(--glass-border);border-radius:100px;padding:0.2rem 0.55rem;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);display:inline-flex;gap:0.35rem;align-items:center;}
|
|
233
|
+
.pk-legend i{width:8px;height:8px;border-radius:2px;}
|
|
234
|
+
|
|
235
|
+
/* robot drawer */
|
|
236
|
+
.pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;
|
|
237
|
+
background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);
|
|
238
|
+
border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);
|
|
239
|
+
transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}
|
|
240
|
+
.pk-drawer.open{transform:translateX(0);}
|
|
241
|
+
.pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}
|
|
242
|
+
.pk-av{width:40px;height:40px;border-radius:11px;background:radial-gradient(circle at 30% 25%,rgba(255,214,112,0.28),rgba(0,0,0,0.25));
|
|
243
|
+
border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}
|
|
244
|
+
.pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}
|
|
245
|
+
.pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}
|
|
246
|
+
.pk-dh .x{margin-left:auto;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);border-radius:9px;width:30px;height:30px;cursor:pointer;font-size:1rem;}
|
|
247
|
+
.pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}
|
|
248
|
+
.pk-pill{font-family:var(--mono);font-size:0.58rem;text-transform:uppercase;letter-spacing:0.04em;padding:0.16em 0.5em;border-radius:100px;border:1px solid;}
|
|
249
|
+
.pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}
|
|
250
|
+
.pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}
|
|
251
|
+
.pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}
|
|
252
|
+
.pk-sect{border-radius:12px;padding:0.75rem 0.8rem;background:var(--panel);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-hi);}
|
|
253
|
+
.pk-sect h4{font-family:var(--mono);font-size:0.58rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.55rem;display:flex;align-items:center;gap:0.4rem;}
|
|
254
|
+
.pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}
|
|
255
|
+
.pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}
|
|
256
|
+
.pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}
|
|
257
|
+
.pk-cam .rec{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:0.35rem;color:#fff;text-shadow:0 1px 3px #000;}
|
|
258
|
+
.pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}
|
|
259
|
+
.pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}
|
|
260
|
+
.pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}
|
|
261
|
+
.pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}
|
|
262
|
+
.pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}
|
|
263
|
+
.pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}
|
|
264
|
+
.pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}
|
|
265
|
+
.pk-val{font-family:var(--mono);font-size:0.66rem;color:var(--ink);width:52px;text-align:right;flex:none;font-variant-numeric:tabular-nums;}
|
|
266
|
+
.pk-wave{height:32px;width:100%;}
|
|
267
|
+
.pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}
|
|
268
|
+
.pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
|
|
269
|
+
.pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}
|
|
270
|
+
.pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}
|
|
271
|
+
.pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}
|
|
272
|
+
.pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}
|
|
273
|
+
.pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}
|
|
274
|
+
.pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}
|
|
275
|
+
.pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
|
|
276
|
+
.pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
|
|
277
|
+
.pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
|
|
168
278
|
</style>
|
|
169
279
|
</head>
|
|
170
280
|
<body>
|
|
@@ -183,6 +293,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
183
293
|
|
|
184
294
|
<div class="tabs">
|
|
185
295
|
<button class="tab active" data-tab="fleet" id="tabbtn-fleet">Fleet</button>
|
|
296
|
+
<button class="tab" data-tab="park" id="tabbtn-park">Park</button>
|
|
186
297
|
<button class="tab" data-tab="robopark" id="tabbtn-robopark">Sessions & Telemetry</button>
|
|
187
298
|
</div>
|
|
188
299
|
|
|
@@ -200,6 +311,33 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
200
311
|
</div>
|
|
201
312
|
</div>
|
|
202
313
|
|
|
314
|
+
<div class="pane" id="pane-park">
|
|
315
|
+
<div class="pk-stage" id="pk-stage">
|
|
316
|
+
<canvas id="pk-canvas"></canvas>
|
|
317
|
+
<div class="pk-legend">
|
|
318
|
+
<span><i style="background:var(--gold-bright)"></i>hub</span>
|
|
319
|
+
<span><i style="background:var(--ok);border-radius:50%"></i>robot</span>
|
|
320
|
+
<span><i style="background:var(--info)"></i>peer</span>
|
|
321
|
+
<span><i style="background:var(--info)"></i>dispatch</span>
|
|
322
|
+
<span><i style="background:var(--ok)"></i>telemetry</span>
|
|
323
|
+
</div>
|
|
324
|
+
<aside class="pk-feed">
|
|
325
|
+
<h3><span>Live activity</span><span style="color:var(--muted)" id="pk-src">/fed/stream</span></h3>
|
|
326
|
+
<div class="rows" id="pk-rows"></div>
|
|
327
|
+
</aside>
|
|
328
|
+
<div class="pk-hint">drag to pan · scroll to zoom · <b>click a robot</b> for cam / mic / speaker</div>
|
|
329
|
+
</div>
|
|
330
|
+
<div class="pk-drawer" id="pk-drawer">
|
|
331
|
+
<div class="pk-dh">
|
|
332
|
+
<div class="pk-av" id="pk-av">🤖</div>
|
|
333
|
+
<div><div class="nm" id="pk-name">robot</div><div class="sub" id="pk-sub">satellite</div></div>
|
|
334
|
+
<span class="pk-pill idle" id="pk-state" style="margin-left:0.4rem">idle</span>
|
|
335
|
+
<button class="x" id="pk-close">✕</button>
|
|
336
|
+
</div>
|
|
337
|
+
<div class="pk-dbody" id="pk-body"></div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
203
341
|
<div class="pane" id="pane-robopark">
|
|
204
342
|
<div class="rp-head">
|
|
205
343
|
<h2 style="margin:0">RoboPark · sessions & telemetry</h2>
|
|
@@ -229,6 +367,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
229
367
|
var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
|
|
230
368
|
var selfId = null, selected = null, lastSeq = -1, everOk = false;
|
|
231
369
|
var rpReadOnly = false, pmBusy = false;
|
|
370
|
+
var lastStatus = null; // most recent /fed/status snapshot (shared with Park)
|
|
371
|
+
var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)
|
|
232
372
|
|
|
233
373
|
var $ = function(id){ return document.getElementById(id); };
|
|
234
374
|
function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }
|
|
@@ -236,19 +376,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
236
376
|
function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }
|
|
237
377
|
function clock(ts){ var d=new Date(ts); function p(n){return (n<10?'0':'')+n;} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }
|
|
238
378
|
|
|
239
|
-
// theme
|
|
379
|
+
// theme (dark glass is the default; toggle to frosted light)
|
|
240
380
|
(function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}
|
|
241
|
-
|
|
381
|
+
document.documentElement.setAttribute('data-theme', s || 'dark');
|
|
242
382
|
$('theme').addEventListener('click', function(){
|
|
243
|
-
var
|
|
244
|
-
var cur=document.documentElement.getAttribute('data-theme')||(m.matches?'dark':'light');
|
|
383
|
+
var cur=document.documentElement.getAttribute('data-theme')||'dark';
|
|
245
384
|
var nx=cur==='dark'?'light':'dark';
|
|
246
385
|
document.documentElement.setAttribute('data-theme', nx);
|
|
247
386
|
try{localStorage.setItem(k,nx)}catch(e){}
|
|
248
387
|
});
|
|
249
388
|
})();
|
|
250
389
|
|
|
251
|
-
// Pull a short human detail out of an event's data payload.
|
|
252
390
|
function detailOf(data){
|
|
253
391
|
var p = data || {};
|
|
254
392
|
if(typeof p==='string') return p;
|
|
@@ -267,11 +405,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
267
405
|
if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% · mem '+m+'%'; }
|
|
268
406
|
if(f.t==='hb'){ return 'heartbeat · load '+(d.load!=null?d.load:'-'); }
|
|
269
407
|
if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }
|
|
270
|
-
// routing decision: compact payload is {k, m, task, w, d}
|
|
271
408
|
if(f.t==='rt'){ var rp=d.d||{}; return 'route → '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }
|
|
272
|
-
// immediate lifecycle event: {k:type, m, task, w, d:payload}
|
|
273
409
|
if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }
|
|
274
|
-
// coalesced chatter: {count, tail:[{k, d}]}
|
|
275
410
|
if(f.t==='log'){
|
|
276
411
|
if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);
|
|
277
412
|
return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' ×'+d.count):''); }
|
|
@@ -290,7 +425,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
290
425
|
var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;
|
|
291
426
|
var html = '';
|
|
292
427
|
for(var i=0;i<activity.length;i++){
|
|
293
|
-
var e = activity[i]; var f = e.f || e;
|
|
428
|
+
var e = activity[i]; var f = e.f || e;
|
|
294
429
|
var name = (f.n||'').slice(0,10);
|
|
295
430
|
html += '<div class="line k-'+esc(f.t)+'"><span class="t">'+clock(f.ts)+'</span>'
|
|
296
431
|
+ '<span class="n" title="'+esc(f.n)+'">'+esc(name)+'</span>'
|
|
@@ -383,24 +518,23 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
383
518
|
if(!r.ok) throw new Error('status '+r.status);
|
|
384
519
|
return r.json();
|
|
385
520
|
}).then(function(s){
|
|
386
|
-
everOk = true; setLive(true);
|
|
521
|
+
everOk = true; setLive(true); lastStatus = s;
|
|
387
522
|
if(!selfId && s.self) selfId = s.self.nodeId;
|
|
388
523
|
if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
|
|
389
524
|
renderSummary(s);
|
|
390
525
|
renderFleet(s.nodes||[]);
|
|
391
526
|
renderHw(s.hardware);
|
|
392
527
|
renderFeed(s.activity||[]);
|
|
528
|
+
Park.onStatus(s);
|
|
393
529
|
}).catch(function(){ setLive(false); });
|
|
394
530
|
}
|
|
395
531
|
|
|
396
|
-
// command execution
|
|
397
532
|
function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }
|
|
398
533
|
$('outClose').addEventListener('click', function(){ $('out').style.display='none'; });
|
|
399
534
|
|
|
400
535
|
function runCommand(input){
|
|
401
536
|
input = (input||'').trim();
|
|
402
537
|
if(!input) return;
|
|
403
|
-
// Target a peer by prefixing the kernel's node-scoped command form.
|
|
404
538
|
var payload = selected ? ('/node ' + selected + ' ' + input) : input;
|
|
405
539
|
$('runBtn').disabled = true;
|
|
406
540
|
fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })
|
|
@@ -421,7 +555,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
421
555
|
.then(function(){ $('applyBtn').disabled=false; });
|
|
422
556
|
});
|
|
423
557
|
|
|
424
|
-
// ── tabs: Fleet + RoboPark sessions/telemetry ──
|
|
558
|
+
// ── tabs: Fleet + Park + RoboPark sessions/telemetry ──
|
|
425
559
|
var tabs = document.querySelectorAll('.tab');
|
|
426
560
|
var activeTab = 'fleet';
|
|
427
561
|
for (var ti=0; ti<tabs.length; ti++){
|
|
@@ -433,19 +567,20 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
433
567
|
activeTab = name;
|
|
434
568
|
for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);
|
|
435
569
|
$('pane-fleet').classList.toggle('active', name==='fleet');
|
|
570
|
+
$('pane-park').classList.toggle('active', name==='park');
|
|
436
571
|
$('pane-robopark').classList.toggle('active', name==='robopark');
|
|
437
572
|
if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
|
|
438
573
|
if (name==='robopark') pollRobopark();
|
|
574
|
+
if (name==='park') Park.activate(); else Park.deactivate();
|
|
439
575
|
}
|
|
440
|
-
|
|
441
|
-
if (
|
|
576
|
+
var initHash = location.hash.replace('#','');
|
|
577
|
+
if (initHash==='robopark'||initHash==='park') selectTab(initHash);
|
|
442
578
|
|
|
443
579
|
function robotsFrom(d){
|
|
444
580
|
if (Array.isArray(d)) return d;
|
|
445
581
|
if (d && Array.isArray(d.robots)) return d.robots;
|
|
446
582
|
return [];
|
|
447
583
|
}
|
|
448
|
-
// Reflect this node's read-only state on the RoboPark controls.
|
|
449
584
|
function applyReadOnly(){
|
|
450
585
|
var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;
|
|
451
586
|
var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;
|
|
@@ -457,7 +592,6 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
457
592
|
} else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }
|
|
458
593
|
}
|
|
459
594
|
|
|
460
|
-
// A write through the scheduler proxy. Handles the read-only 403 gracefully.
|
|
461
595
|
function rpAction(method, path, body){
|
|
462
596
|
if(rpReadOnly){ showOut('This node is read-only — restart the hub with --token to enable control actions.'); return Promise.resolve(null); }
|
|
463
597
|
return fetch(path+QS, {
|
|
@@ -470,7 +604,6 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
470
604
|
});
|
|
471
605
|
}
|
|
472
606
|
|
|
473
|
-
// production_mode master toggle
|
|
474
607
|
function setPm(on){
|
|
475
608
|
var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');
|
|
476
609
|
if(!wrap) return;
|
|
@@ -500,10 +633,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
500
633
|
function endSession(id){
|
|
501
634
|
if(!confirm('End the active session for '+id+'?')) return;
|
|
502
635
|
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){
|
|
503
|
-
showOut('ended session for '+id); pollRobopark();
|
|
636
|
+
showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();
|
|
504
637
|
}).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });
|
|
505
638
|
}
|
|
506
|
-
// event-delegated action buttons (cards are re-rendered each poll)
|
|
507
639
|
$('rp-grid').addEventListener('click', function(e){
|
|
508
640
|
var b=e.target.closest?e.target.closest('.rp-btn'):null; if(!b) return;
|
|
509
641
|
var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');
|
|
@@ -514,6 +646,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
514
646
|
pollSettings();
|
|
515
647
|
fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
|
|
516
648
|
var grid = $('rp-grid'), totals = $('rp-totals');
|
|
649
|
+
// stash per-robot rows so the Park drawer can show live session stats
|
|
650
|
+
roboparkByName = {};
|
|
651
|
+
robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; });
|
|
517
652
|
if (d && (d.disabled || d.ok===false)){
|
|
518
653
|
totals.innerHTML=''; $('pm-wrap').hidden=true;
|
|
519
654
|
grid.innerHTML = '<div class="empty" style="grid-column:1/-1">'+esc(d.error||'no scheduler linked')+' — start the hub with <span class="mono">--scheduler-url http://<scheduler>:8080</span></div>';
|
|
@@ -552,6 +687,354 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
552
687
|
});
|
|
553
688
|
}
|
|
554
689
|
|
|
690
|
+
// ══════════════════════════════════════════════════════════════════
|
|
691
|
+
// Park — isometric live map of the mesh
|
|
692
|
+
// Real data: /fed/status nodes (placement + load) + /fed/stream frames
|
|
693
|
+
// (agent movement + tool-use pulses) + /robopark telemetry (session stats).
|
|
694
|
+
// Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.
|
|
695
|
+
// ══════════════════════════════════════════════════════════════════
|
|
696
|
+
var Park = (function(){
|
|
697
|
+
var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1};
|
|
698
|
+
var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
|
|
699
|
+
var TW=124, TH=62;
|
|
700
|
+
var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;
|
|
701
|
+
var RING=[[2,0],[1,2],[-1,2],[-2,0],[-1,-2],[1,-2],[3,1],[3,-1],[0,3],[-3,1],[-3,-1],[0,-3],[2,3],[-2,3],[2,-3],[-2,-3],[4,0],[0,4],[-4,0],[0,-4]];
|
|
702
|
+
|
|
703
|
+
function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }
|
|
704
|
+
function kindOf(n){
|
|
705
|
+
var r=(n.role||'').toLowerCase();
|
|
706
|
+
if(r==='hub'||r==='scheduler') return 'hub';
|
|
707
|
+
if(r==='satellite'||r==='robot') return 'robot';
|
|
708
|
+
var caps=(n.capabilities||[]).join(' ');
|
|
709
|
+
if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';
|
|
710
|
+
return 'peer';
|
|
711
|
+
}
|
|
712
|
+
function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
|
|
713
|
+
function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
|
|
714
|
+
|
|
715
|
+
function layout(src){
|
|
716
|
+
var list=(src||[]).slice().sort(function(a,b){ return (a.nodeId<b.nodeId?-1:1); });
|
|
717
|
+
var hub=null; for(var i=0;i<list.length;i++){ if(kindOf(list[i])==='hub'){ hub=list[i]; break; } }
|
|
718
|
+
if(!hub && list.length) hub=list[0];
|
|
719
|
+
var out=[], oi=0;
|
|
720
|
+
list.forEach(function(n){
|
|
721
|
+
var cell = (n===hub)? [0,0] : RING[(oi++)%RING.length];
|
|
722
|
+
var prev = byId[n.nodeId];
|
|
723
|
+
out.push({
|
|
724
|
+
id:n.nodeId, name:n.displayName||n.nodeId, role:n.role||'?', kind:kindOf(n),
|
|
725
|
+
connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',
|
|
726
|
+
site:n.site||'', caps:n.capabilities||[], gx:cell[0], gy:cell[1], _hit:prev?prev._hit:null
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function onStatus(s){
|
|
733
|
+
if(!s) return;
|
|
734
|
+
nodes = layout(s.nodes||[]);
|
|
735
|
+
byId={}; nodes.forEach(function(n){ byId[n.id]=n; });
|
|
736
|
+
// if not streaming, animate from the polled activity tail
|
|
737
|
+
if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }
|
|
741
|
+
function findByFrame(f){
|
|
742
|
+
var key=f.n; if(key==null) return null;
|
|
743
|
+
for(var i=0;i<nodes.length;i++){ if(nodes[i].name===key||nodes[i].id===key) return nodes[i]; }
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
function ingest(f, fromPoll){
|
|
747
|
+
if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }
|
|
748
|
+
var n=findByFrame(f), h=hub();
|
|
749
|
+
if(n && h){
|
|
750
|
+
if(n.kind==='hub'){ pulse(n, glyphFor(f)); }
|
|
751
|
+
else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }
|
|
752
|
+
else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }
|
|
753
|
+
}
|
|
754
|
+
feedRow(f);
|
|
755
|
+
}
|
|
756
|
+
function glyphFor(f){
|
|
757
|
+
if(f.t==='rt') return '⇄';
|
|
758
|
+
if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '⌨'; if(/session|presence/.test(k)) return '🎙'; return '✦'; }
|
|
759
|
+
if(f.t==='hw'||f.t==='hb') return '';
|
|
760
|
+
return '✦';
|
|
761
|
+
}
|
|
762
|
+
function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }
|
|
763
|
+
function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'✦'}); }
|
|
764
|
+
|
|
765
|
+
function feedRow(f){
|
|
766
|
+
var rows=$('pk-rows'); if(!rows) return;
|
|
767
|
+
var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;
|
|
768
|
+
var r=document.createElement('div'); r.className='pk-row';
|
|
769
|
+
r.innerHTML='<span class="k pk-k-'+esc(kind)+'"></span><span class="t">'+clock(f.ts)+'</span>'
|
|
770
|
+
+'<span class="who" title="'+esc(f.n)+'">'+esc((f.n||'').slice(0,10))+'</span>'
|
|
771
|
+
+'<span class="msg">'+esc(summarizeFrame(f))+'</span>';
|
|
772
|
+
rows.insertBefore(r, rows.firstChild);
|
|
773
|
+
while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ── canvas + projection ──
|
|
777
|
+
function ensure(){
|
|
778
|
+
if(C) return;
|
|
779
|
+
C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();
|
|
780
|
+
DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));
|
|
781
|
+
var d=false,px,py;
|
|
782
|
+
C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});
|
|
783
|
+
window.addEventListener('mouseup',function(){d=false;});
|
|
784
|
+
window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });
|
|
785
|
+
C.addEventListener('wheel',function(e){ e.preventDefault(); var f=e.deltaY<0?1.08:0.925; cam.z=Math.max(.5,Math.min(2.4,cam.z*f)); },{passive:false});
|
|
786
|
+
C.addEventListener('click',function(e){
|
|
787
|
+
var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;
|
|
788
|
+
for(var i=0;i<nodes.length;i++){ var hb=nodes[i]._hit; if(hb&&mx>=hb.x&&mx<=hb.x+hb.w&&my>=hb.y&&my<=hb.y+hb.h) hit=nodes[i]; }
|
|
789
|
+
if(hit){ openDrawer(hit.id); selectNode(hit.id, hit.name); }
|
|
790
|
+
});
|
|
791
|
+
window.addEventListener('resize', function(){ if(running) resize(); });
|
|
792
|
+
}
|
|
793
|
+
function resize(){
|
|
794
|
+
var st=$('pk-stage'); if(!st) return;
|
|
795
|
+
W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);
|
|
796
|
+
}
|
|
797
|
+
function iso(gx,gy){ return { x: W/2 + cam.x + (gx-gy)*TW/2*cam.z, y: H*0.42 + cam.y + (gx+gy)*TH/2*cam.z }; }
|
|
798
|
+
|
|
799
|
+
function tile(gx,gy,raised){
|
|
800
|
+
var p=iso(gx,gy), z=cam.z, hw=TW/2*z, hh=TH/2*z, dz=raised?13*z:0;
|
|
801
|
+
X.beginPath(); X.moveTo(p.x,p.y-hh-dz); X.lineTo(p.x+hw,p.y-dz); X.lineTo(p.x,p.y+hh-dz); X.lineTo(p.x-hw,p.y-dz); X.closePath();
|
|
802
|
+
var g=X.createLinearGradient(p.x-hw,p.y-hh,p.x+hw,p.y+hh);
|
|
803
|
+
g.addColorStop(0,raised?'rgba(60,52,86,0.55)':'rgba(30,26,44,0.32)'); g.addColorStop(1,raised?'rgba(20,17,32,0.6)':'rgba(16,14,26,0.28)');
|
|
804
|
+
X.fillStyle=g; X.fill();
|
|
805
|
+
X.strokeStyle=raised?'rgba(255,214,112,0.55)':'rgba(255,255,255,0.08)'; X.lineWidth=1; X.stroke();
|
|
806
|
+
if(raised){
|
|
807
|
+
X.beginPath(); X.moveTo(p.x-hw,p.y-dz); X.lineTo(p.x-hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();
|
|
808
|
+
X.fillStyle='rgba(10,8,18,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.28)'; X.stroke();
|
|
809
|
+
X.beginPath(); X.moveTo(p.x+hw,p.y-dz); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x,p.y+hh-dz); X.closePath();
|
|
810
|
+
X.fillStyle='rgba(14,11,24,0.55)'; X.fill(); X.strokeStyle='rgba(255,214,112,0.2)'; X.stroke();
|
|
811
|
+
}
|
|
812
|
+
return p;
|
|
813
|
+
}
|
|
814
|
+
function ring(cx,cy,r,frac,color){
|
|
815
|
+
X.beginPath(); X.arc(cx,cy,r,0,Math.PI*2); X.strokeStyle='rgba(255,255,255,0.12)'; X.lineWidth=3*cam.z; X.stroke();
|
|
816
|
+
X.beginPath(); X.arc(cx,cy,r,-Math.PI/2,-Math.PI/2+Math.PI*2*Math.max(0,Math.min(1,frac))); X.strokeStyle=color; X.lineWidth=3*cam.z; X.stroke();
|
|
817
|
+
}
|
|
818
|
+
function label(cx,cy,txt,col,sub){
|
|
819
|
+
var z=cam.z; X.textAlign='center';
|
|
820
|
+
X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);
|
|
821
|
+
X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);
|
|
822
|
+
}
|
|
823
|
+
function drawHub(n){
|
|
824
|
+
var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-14*z-bob;
|
|
825
|
+
X.fillStyle='rgba(40,34,58,0.9)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
|
|
826
|
+
X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();
|
|
827
|
+
for(var i=0;i<4;i++){ X.fillStyle=i%2?getVar('--ok'):getVar('--gold-bright'); X.globalAlpha=0.4+0.6*Math.abs(Math.sin((performance.now()-t0)/300+i)); X.fillRect(bx-11*z,by-34*z+i*9*z,22*z,4*z); X.globalAlpha=1; }
|
|
828
|
+
X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();
|
|
829
|
+
X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();
|
|
830
|
+
label(bx,p.y+18*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
|
|
831
|
+
n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};
|
|
832
|
+
}
|
|
833
|
+
function drawRobot(n){
|
|
834
|
+
var p=tile(n.gx,n.gy,true), z=cam.z, bob=Math.sin((performance.now()-t0)/500+n.gx)*2.4*z, bx=p.x, by=p.y-10*z-bob;
|
|
835
|
+
ring(bx,p.y+2*z,20*z,n.load/100,loadColor(n.load));
|
|
836
|
+
X.fillStyle='rgba(0,0,0,0.35)'; X.beginPath(); X.ellipse(bx,p.y+2*z,15*z,7*z,0,0,7); X.fill();
|
|
837
|
+
var col=n.connected?getVar('--gold-bright'):'rgba(150,140,120,0.5)';
|
|
838
|
+
X.fillStyle='rgba(24,20,38,0.9)'; X.strokeStyle=col; X.lineWidth=1.6;
|
|
839
|
+
X.beginPath(); X.roundRect(bx-12*z,by-24*z,24*z,26*z,5*z); X.fill(); X.stroke();
|
|
840
|
+
X.beginPath(); X.roundRect(bx-9*z,by-38*z,18*z,15*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();
|
|
841
|
+
X.fillStyle=n.connected?getVar('--ok'):'#654'; X.beginPath(); X.arc(bx-4*z,by-30*z,2.1*z,0,7); X.arc(bx+4*z,by-30*z,2.1*z,0,7); X.fill();
|
|
842
|
+
X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-38*z); X.lineTo(bx,by-45*z); X.stroke();
|
|
843
|
+
X.fillStyle=n.connected?'#FF5B4A':'#654'; X.beginPath(); X.arc(bx,by-46*z,2.2*z,0,7); X.fill();
|
|
844
|
+
label(bx,p.y+18*z,n.name,col,(n.connected?'load '+Math.round(n.load)+'%':'offline'));
|
|
845
|
+
n._hit={x:bx-16*z,y:by-48*z,w:32*z,h:62*z};
|
|
846
|
+
}
|
|
847
|
+
function drawPeer(n){
|
|
848
|
+
var p=tile(n.gx,n.gy,true), z=cam.z, bx=p.x, by=p.y-8*z;
|
|
849
|
+
var col=n.connected?getVar('--info'):'rgba(150,140,120,0.5)';
|
|
850
|
+
X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;
|
|
851
|
+
X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();
|
|
852
|
+
X.fillStyle=col; X.globalAlpha=0.5+0.5*Math.abs(Math.sin((performance.now()-t0)/500)); X.fillRect(bx-8*z,by-14*z,16*z,3*z); X.globalAlpha=1;
|
|
853
|
+
label(bx,p.y+16*z,n.name,col,(n.connected?n.role:'offline'));
|
|
854
|
+
n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function frame(){
|
|
858
|
+
if(!running) return;
|
|
859
|
+
X.clearRect(0,0,W,H);
|
|
860
|
+
for(var gx=-4;gx<=4;gx++) for(var gy=-4;gy<=4;gy++){ if(!nodes.some(function(n){return n.gx===gx&&n.gy===gy;})) faint(gx,gy); }
|
|
861
|
+
var sorted=nodes.slice().sort(function(a,b){ return (a.gx+a.gy)-(b.gx+b.gy); });
|
|
862
|
+
sorted.forEach(function(n){ if(n.kind==='hub') drawHub(n); else if(n.kind==='robot') drawRobot(n); else drawPeer(n); });
|
|
863
|
+
for(var i=orbs.length-1;i>=0;i--){ var o=orbs[i]; o.t+=0.024; if(o.t>=1){orbs.splice(i,1);continue;}
|
|
864
|
+
var a=iso(o.a.gx,o.a.gy), b=iso(o.b.gx,o.b.gy), e=o.t<.5?2*o.t*o.t:-1+(4-2*o.t)*o.t;
|
|
865
|
+
var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o.t*Math.PI)*40*cam.z;
|
|
866
|
+
X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o.color; X.shadowColor=o.color; X.shadowBlur=12; X.fill(); X.shadowBlur=0;
|
|
867
|
+
}
|
|
868
|
+
for(var j=pulses.length-1;j>=0;j--){ var pu=pulses[j]; pu.t+=0.02; if(pu.t>=1){pulses.splice(j,1);continue;}
|
|
869
|
+
var pp=iso(pu.n.gx,pu.n.gy), yy=pp.y-58*cam.z-pu.t*24*cam.z;
|
|
870
|
+
X.globalAlpha=1-pu.t; X.font='700 '+(15*cam.z)+'px '+mono(); X.textAlign='center'; X.fillStyle=getVar('--gold-bright'); X.fillText(pu.g,pp.x,yy);
|
|
871
|
+
X.beginPath(); X.arc(pp.x,pp.y-20*cam.z,(10+pu.t*22)*cam.z,0,7); X.strokeStyle='rgba(255,214,112,'+(1-pu.t)+')'; X.lineWidth=2; X.stroke(); X.globalAlpha=1;
|
|
872
|
+
}
|
|
873
|
+
raf=requestAnimationFrame(frame);
|
|
874
|
+
}
|
|
875
|
+
function faint(gx,gy){ var p=iso(gx,gy),z=cam.z,hw=TW/2*z,hh=TH/2*z;
|
|
876
|
+
X.beginPath(); X.moveTo(p.x,p.y-hh); X.lineTo(p.x+hw,p.y); X.lineTo(p.x,p.y+hh); X.lineTo(p.x-hw,p.y); X.closePath();
|
|
877
|
+
X.strokeStyle='rgba(255,255,255,0.045)'; X.lineWidth=1; X.stroke();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function connectStream(){
|
|
881
|
+
if(es) return;
|
|
882
|
+
try{
|
|
883
|
+
es=new EventSource('/fed/stream'+QS);
|
|
884
|
+
es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };
|
|
885
|
+
es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream · live'; };
|
|
886
|
+
es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status · poll'; };
|
|
887
|
+
}catch(e){ es=null; }
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function activate(){
|
|
891
|
+
ensure();
|
|
892
|
+
if(lastStatus) onStatus(lastStatus);
|
|
893
|
+
resize();
|
|
894
|
+
connectStream();
|
|
895
|
+
if(!running){ running=true; raf=requestAnimationFrame(frame); }
|
|
896
|
+
}
|
|
897
|
+
function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }
|
|
898
|
+
|
|
899
|
+
// ── robot drawer ──
|
|
900
|
+
function sect(title, inner){ return '<div class="pk-sect"><h4>'+title+'</h4>'+inner+'</div>'; }
|
|
901
|
+
function openDrawer(id){
|
|
902
|
+
var n=byId[id]; if(!n) return; drawerId=id;
|
|
903
|
+
$('pk-drawer').classList.add('open');
|
|
904
|
+
$('pk-av').textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
|
|
905
|
+
$('pk-name').textContent=n.name;
|
|
906
|
+
$('pk-sub').textContent=n.role+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'');
|
|
907
|
+
var st=$('pk-state'); st.textContent=n.connected?'live':'idle'; st.className='pk-pill '+(n.connected?'live':'idle');
|
|
908
|
+
buildDrawer(n);
|
|
909
|
+
pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick
|
|
910
|
+
if(drawerTimer) clearInterval(drawerTimer);
|
|
911
|
+
drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);
|
|
912
|
+
}
|
|
913
|
+
function closeDrawer(){
|
|
914
|
+
$('pk-drawer').classList.remove('open'); drawerId=null;
|
|
915
|
+
if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;
|
|
916
|
+
if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
|
|
917
|
+
if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
|
|
918
|
+
if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
|
|
919
|
+
}
|
|
920
|
+
function pollRoboparkQuiet(){
|
|
921
|
+
fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
|
|
922
|
+
var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};
|
|
923
|
+
list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; });
|
|
924
|
+
refreshDrawer();
|
|
925
|
+
}).catch(function(){});
|
|
926
|
+
}
|
|
927
|
+
function buildDrawer(n){
|
|
928
|
+
var b=$('pk-body');
|
|
929
|
+
if(n.kind==='robot'){
|
|
930
|
+
b.innerHTML =
|
|
931
|
+
sect('👁 camera feed',
|
|
932
|
+
'<div class="pk-cam" id="pk-cam"><canvas id="pk-camcv" width="384" height="216"></canvas>'
|
|
933
|
+
+'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
|
|
934
|
+
+'<div class="pk-note" id="pk-camnote">seeking LiveKit track…</div>')
|
|
935
|
+
+ sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
|
|
936
|
+
+ sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">awaiting robot audio plugin</div>')
|
|
937
|
+
+ sect('📊 hardware','<div class="pk-hwgrid"><div><div class="l">load</div><div class="track"><div class="fill" id="pk-hw-load"></div></div></div><div><div class="l">status</div><div class="track"><div class="fill" id="pk-hw-conn" style="background:'+(n.connected?'var(--ok)':'var(--muted)')+'"></div></div></div></div><div class="pk-note">remote node — full CPU/mem telemetry shows on the node itself</div>')
|
|
938
|
+
+ sect('🗓 session telemetry','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-trig">—</div><div class="l">triggers</div></div><div class="pk-kv"><div class="v" id="pk-tv-sess">—</div><div class="l">sessions</div></div><div class="pk-kv"><div class="v" id="pk-tv-lat">—</div><div class="l">avg latency</div></div><div class="pk-kv"><div class="v" id="pk-tv-drop">—</div><div class="l">drop rate</div></div></div>')
|
|
939
|
+
+ sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn danger" id="pk-end" data-robot="'+esc(n.name)+'">End session</button><button class="rp-btn" id="pk-follow">Follow run</button></div><div class="pk-note">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>');
|
|
940
|
+
startCam(n); startMeters(n);
|
|
941
|
+
var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(n.name); });
|
|
942
|
+
var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });
|
|
943
|
+
} else {
|
|
944
|
+
b.innerHTML =
|
|
945
|
+
sect('🖥 node','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-load">—</div><div class="l">load</div></div><div class="pk-kv"><div class="v '+(n.connected?'ok':'')+'">'+(n.connected?'up':'down')+'</div><div class="l">status</div></div></div>')
|
|
946
|
+
+ sect('🔌 capabilities', (n.caps.length?('<div class="caps">'+n.caps.map(function(c){return '<span class="cap">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class="pk-note">none reported</div>'))
|
|
947
|
+
+ sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn" id="pk-status">Run /status</button></div>');
|
|
948
|
+
var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });
|
|
949
|
+
}
|
|
950
|
+
refreshDrawer();
|
|
951
|
+
}
|
|
952
|
+
function refreshDrawer(){
|
|
953
|
+
var n=drawerId?byId[drawerId]:null; if(!n) return;
|
|
954
|
+
setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);
|
|
955
|
+
var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
|
|
956
|
+
var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
|
|
957
|
+
var row=roboparkByName[n.name];
|
|
958
|
+
if(row){
|
|
959
|
+
set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
|
|
960
|
+
set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
|
|
961
|
+
set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'—');
|
|
962
|
+
var dEl=$('pk-tv-drop'); if(dEl){ var d=row.drop_rate!=null?Math.round(row.drop_rate*100):null; dEl.textContent=d!=null?d+'%':'—'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }
|
|
963
|
+
var active=row.active_session||row.in_session||row.session_active;
|
|
964
|
+
var st=$('pk-state'); if(st){ st.textContent=active?'in session':(n.connected?'live':'idle'); st.className='pk-pill '+(active?'live':(n.connected?'live':'idle')); }
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function set(id,v){ var e=$(id); if(e) e.textContent=v; }
|
|
968
|
+
function setFill(id,pct){ var e=$(id); if(!e||pct==null) return; e.style.width=Math.max(2,Math.min(100,pct))+'%'; e.style.background=pct>=75?'var(--bad)':pct>=45?'var(--warn)':'var(--gold-bright)'; }
|
|
969
|
+
|
|
970
|
+
// ── cam: target a LiveKit track; fall back to a styled preview ──
|
|
971
|
+
function startCam(n){
|
|
972
|
+
var note=$('pk-camnote'), res=$('pk-camres');
|
|
973
|
+
camAbort = ('AbortController' in window)?new AbortController():null;
|
|
974
|
+
// The robot-side RoboPark plugin exposes this; returns {url, token, room, trackSid}.
|
|
975
|
+
fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
|
|
976
|
+
.then(function(r){ if(!r.ok) throw new Error('no stream'); return r.json(); })
|
|
977
|
+
.then(function(info){
|
|
978
|
+
if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); }
|
|
979
|
+
else if(info && info.url){ if(note) note.textContent='LiveKit track ready — load livekit-client to view'; previewCam(n); }
|
|
980
|
+
else throw new Error('no track');
|
|
981
|
+
})
|
|
982
|
+
.catch(function(){ if(note) note.textContent='no published cam track — showing preview (robot plugin pending)'; if(res) res.textContent='preview'; previewCam(n); });
|
|
983
|
+
}
|
|
984
|
+
function attachLiveKit(info, n){
|
|
985
|
+
var mount=$('pk-cam'), note=$('pk-camnote'), res=$('pk-camres'); if(!mount) return;
|
|
986
|
+
try{
|
|
987
|
+
var LK=window.LivekitClient, room=new LK.Room();
|
|
988
|
+
room.connect(info.url, info.token).then(function(){
|
|
989
|
+
if(note) note.textContent='LiveKit · connected';
|
|
990
|
+
room.on(LK.RoomEvent.TrackSubscribed, function(track){
|
|
991
|
+
if(track.kind==='video'){ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true;
|
|
992
|
+
var cv=$('pk-camcv'); if(cv&&cv.parentNode){ cv.parentNode.replaceChild(el, cv); } if(res) res.textContent='LiveKit · live'; }
|
|
993
|
+
});
|
|
994
|
+
}).catch(function(){ if(note) note.textContent='LiveKit connect failed — preview'; previewCam(n); });
|
|
995
|
+
}catch(e){ if(note) note.textContent='LiveKit error — preview'; previewCam(n); }
|
|
996
|
+
}
|
|
997
|
+
function previewCam(n){
|
|
998
|
+
var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();
|
|
999
|
+
var res=$('pk-camres'); if(res && res.textContent==='— · —') res.textContent='preview · 24fps';
|
|
1000
|
+
(function loop(){ if(!$('pk-camcv')||drawerId==null) return;
|
|
1001
|
+
var tt=(performance.now()-s0)/1000;
|
|
1002
|
+
var g=cx.createLinearGradient(0,0,0,h); g.addColorStop(0,'#0a1420'); g.addColorStop(1,'#132233'); cx.fillStyle=g; cx.fillRect(0,0,w,h);
|
|
1003
|
+
cx.fillStyle='#0e1a12'; cx.beginPath(); cx.moveTo(0,h*0.62); cx.lineTo(w,h*0.62); cx.lineTo(w,h); cx.lineTo(0,h); cx.fill();
|
|
1004
|
+
var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;
|
|
1005
|
+
cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();
|
|
1006
|
+
cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);
|
|
1007
|
+
cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);
|
|
1008
|
+
cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);
|
|
1009
|
+
cx.globalAlpha=0.06; for(var y=0;y<h;y+=3){ cx.fillStyle=y%2?'#fff':'#000'; cx.fillRect(0,y,w,1); } cx.globalAlpha=1;
|
|
1010
|
+
for(var k=0;k<30;k++){ cx.fillStyle='rgba(255,255,255,'+(Math.random()*0.05)+')'; cx.fillRect(Math.random()*w,Math.random()*h,1,1); }
|
|
1011
|
+
var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());
|
|
1012
|
+
camRAF=requestAnimationFrame(loop);
|
|
1013
|
+
})();
|
|
1014
|
+
}
|
|
1015
|
+
function bars(el,n){ el.innerHTML=''; for(var i=0;i<n;i++){ var b=document.createElement('i'); el.appendChild(b); } return el.querySelectorAll('i'); }
|
|
1016
|
+
function startMeters(n){
|
|
1017
|
+
var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;
|
|
1018
|
+
var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw.getContext('2d'), s0=performance.now();
|
|
1019
|
+
// Preview envelopes until the robot audio plugin streams real levels.
|
|
1020
|
+
(function loop(){ if(!$('pk-micbars')||drawerId==null) return;
|
|
1021
|
+
var tt=(performance.now()-s0)/1000;
|
|
1022
|
+
var env=Math.max(0,Math.sin(tt*2.3))*Math.max(0,Math.sin(tt*0.7));
|
|
1023
|
+
for(var i=0;i<mi.length;i++){ var v=env*(0.4+0.6*Math.random())*(i<env*mi.length?1:0.25); mi[i].style.height=(3+v*13)+'px'; mi[i].style.background=i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'; }
|
|
1024
|
+
set('pk-micval', Math.round(env*100)+'%'); set('pk-micstate', env>0.3?'preview · speech':'preview · quiet');
|
|
1025
|
+
var spk=Math.max(0,Math.sin(tt*1.1-1)), speaking=spk>0.35;
|
|
1026
|
+
for(var j=0;j<si.length;j++){ var vv=speaking?(0.3+0.7*Math.random()):0.05; si[j].style.height=(3+vv*13)+'px'; si[j].style.background=speaking?'var(--gold-bright)':'var(--glass-border)'; }
|
|
1027
|
+
set('pk-spkval', speaking?Math.round(spk*100)+'%':'idle'); set('pk-spkstate', speaking?'preview · TTS':'preview · idle');
|
|
1028
|
+
swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle=speaking?getVar('--gold-bright'):'rgba(255,255,255,0.12)'; swx.lineWidth=1.5; swx.beginPath();
|
|
1029
|
+
for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.15+tt*8)*(speaking?spk*11:1.4)*Math.sin(x*0.03); if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke();
|
|
1030
|
+
meterRAF=requestAnimationFrame(loop);
|
|
1031
|
+
})();
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
$('pk-close') && $('pk-close').addEventListener('click', closeDrawer);
|
|
1035
|
+
return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer };
|
|
1036
|
+
})();
|
|
1037
|
+
|
|
555
1038
|
poll();
|
|
556
1039
|
setInterval(poll, 2000);
|
|
557
1040
|
setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);
|
package/package.json
CHANGED