infinicode 2.6.2 → 2.7.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.
- package/dist/cli.js +16 -1
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +126 -14
- package/dist/kernel/federation/telemetry.d.ts +5 -0
- package/dist/kernel/federation/telemetry.js +72 -9
- package/dist/kernel/federation/types.d.ts +3 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -19,7 +19,22 @@ import { meshInstall, meshUninstall } from './commands/mesh-install.js';
|
|
|
19
19
|
import { installTui } from './commands/install-tui.js';
|
|
20
20
|
import { attachRoboparkSubcommands } from './commands/robopark.js';
|
|
21
21
|
import { DEFAULT_CONFIG } from './kernel/config-schema.js';
|
|
22
|
-
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
// Read the real version from package.json so `--version` never drifts from what
|
|
26
|
+
// is actually installed (this used to be a hardcoded constant that went stale).
|
|
27
|
+
function pkgVersion() {
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(join(here, rel), 'utf8')).version ?? '0.0.0';
|
|
32
|
+
}
|
|
33
|
+
catch { /* try next */ }
|
|
34
|
+
}
|
|
35
|
+
return '0.0.0';
|
|
36
|
+
}
|
|
37
|
+
const VERSION = pkgVersion();
|
|
23
38
|
const config = new Conf({
|
|
24
39
|
projectName: 'infinicode',
|
|
25
40
|
defaults: DEFAULT_CONFIG,
|
|
@@ -22,4 +22,4 @@
|
|
|
22
22
|
* Kept free of backticks and ${...} so the whole page fits inside this TS
|
|
23
23
|
* template literal without escaping; the client JS uses string concatenation.
|
|
24
24
|
*/
|
|
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 <button class=\"act ghost\" id=\"updateBtn\" title=\"npm-update every node to the latest release and restart (supervised nodes come back on their own)\">Update 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 $('updateBtn').addEventListener('click', function(){\n if(!confirm('Update every node to the latest npm release and restart them?\\n\\nSupervised nodes relaunch automatically; the hub itself will blip while it restarts.')) return;\n $('updateBtn').disabled = true;\n showOut('Updating fleet \u2014 installing latest on each node\u2026');\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })\n .catch(function(e){ showOut('update failed: '+e.message); })\n .then(function(){ $('updateBtn').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}, fitted=false;\n var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;\n var lkRoom=null, micAnalyser=null, audioCtx=null;\n var TW=44, TH=22, PARK_W=15, PARK_D=8;\n // The real park (from the aerial): named robot pads at their ground\n // positions, plus palms, pond, and the street. A mesh node binds to a pad\n // when its display name matches the pad keywords (robobmw \u2192 VIXEN/BMW).\n var SLOTS=[\n {label:'VOLT', keys:['volt'], nx:0.30, ny:0.22},\n {label:'TESLA', keys:['tesla'], nx:0.44, ny:0.36},\n {label:'JAGUAR', keys:['jaguar','jag'], nx:0.60, ny:0.40},\n {label:'VIXEN', keys:['vixen','bmw'], nx:0.55, ny:0.66, sub:'BMW'},\n {label:'PANDA', keys:['panda'], nx:0.84, ny:0.58}\n ];\n var HUBSLOT={label:'CONTROL', keys:[], nx:0.09, ny:0.12};\n var PALMS=[[0.11,0.55],[0.15,0.74],[0.19,0.42],[0.23,0.64],[0.17,0.31],[0.25,0.82],[0.37,0.24],[0.41,0.56],[0.49,0.74],[0.52,0.29],[0.59,0.62],[0.66,0.26],[0.71,0.69],[0.76,0.42],[0.82,0.74],[0.89,0.42],[0.92,0.68],[0.67,0.83],[0.34,0.71]];\n var POND={cx:0.33*15, cy:0.99*8, rx:3.1, ry:1.4};\n var CARS=[[4,-0.95,'#e0a23c'],[6,-0.8,'#7fb4ff'],[8,-1.0,'#ff7e6e'],[10.5,-0.78,'#d8d8e0'],[12.8,-0.95,'#f2c14e']];\n\n function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }\n function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }\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 // Bind mesh nodes onto the fixed park pads; unmatched pads render as\n // offline placeholders so the whole park is always visible.\n function layout(src){\n var list=(src||[]).slice(), used={}, out=[];\n function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }\n function bind(slot, wx, wy, kind){\n var match=null;\n for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;\n var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';\n if(kind==='hub'){ if(isHub){ match=n; break; } }\n else if(!isHub){ for(var k=0;k<slot.keys.length;k++){ if(slot.keys[k] && nm.indexOf(slot.keys[k])>-1){ match=n; break; } } if(match) break; }\n }\n var id = match?match.nodeId:('pad:'+slot.label);\n if(match) used[match.nodeId]=1;\n return {\n id:id, kind:kind, label:slot.label,\n name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),\n sub: match?(slot.sub||''):'not joined',\n connected: match?!!match.connected:false,\n load: (match&&match.load!=null)?match.load:0,\n platform: match?(match.platform||''):'', version: match?(match.version||'?'):'\u2014',\n site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],\n telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),\n wx:wx, wy:wy, _hit:prevHit(id)\n };\n }\n var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));\n SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });\n // Any extra live node that matched no pad \u2192 line it along the near edge.\n var ex=0;\n list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;\n var w=W2(0.20+ex*0.13, 0.90); ex++;\n out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),\n sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',\n site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });\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==null?'':String(f.n)).toLowerCase(); if(!key) return null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i];\n if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;\n for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }\n }\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); if(String(hit.id).indexOf('pad:')!==0) 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 if(!fitted){ fit(); fitted=true; }\n }\n function fit(){\n var span=PARK_W+PARK_D;\n var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);\n cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;\n }\n // World (park) coords \u2192 screen. Origin biased left so the right-docked feed\n // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).\n function iso(wx,wy){\n var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);\n return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };\n }\n function poly(pts, fill, stroke, lw){\n X.beginPath();\n for(var i=0;i<pts.length;i++){ var p=iso(pts[i][0],pts[i][1]); if(i===0)X.moveTo(p.x,p.y); else X.lineTo(p.x,p.y); }\n X.closePath();\n if(fill){ X.fillStyle=fill; X.fill(); }\n if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }\n }\n function drawGround(){\n // grass base\n var g=X.createLinearGradient(0,0,0,H); g.addColorStop(0,'rgba(38,68,44,0.55)'); g.addColorStop(1,'rgba(22,44,28,0.5)');\n poly([[-2,-2.2],[PARK_W+2,-2.2],[PARK_W+2,PARK_D+2],[-2,PARK_D+2]], g, 'rgba(120,180,120,0.10)',1);\n // street band along the far edge + dashed centerline + cars\n poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.88)', 'rgba(255,255,255,0.08)',1);\n X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);\n var r1=iso(0.2,-0.92), r2=iso(PARK_W-0.2,-0.92); X.beginPath(); X.moveTo(r1.x,r1.y); X.lineTo(r2.x,r2.y); X.stroke(); X.setLineDash([]);\n CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });\n // paved plaza\n var px0=0.4,py0=-0.15,px1=PARK_W-0.4,py1=PARK_D-0.6;\n var pg=X.createLinearGradient(0,0,0,H); pg.addColorStop(0,'rgba(74,70,92,0.66)'); pg.addColorStop(1,'rgba(46,42,60,0.62)');\n poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);\n X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;\n for(var gx=Math.ceil(px0);gx<px1;gx++){ var a=iso(gx,py0), b=iso(gx,py1); X.beginPath(); X.moveTo(a.x,a.y); X.lineTo(b.x,b.y); X.stroke(); }\n for(var gy=Math.ceil(py0);gy<py1;gy++){ var c=iso(px0,gy), d=iso(px1,gy); X.beginPath(); X.moveTo(c.x,c.y); X.lineTo(d.x,d.y); X.stroke(); }\n // pond\n var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/9){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)]); }\n var wg=X.createLinearGradient(0,0,0,H); wg.addColorStop(0,'rgba(44,116,156,0.72)'); wg.addColorStop(1,'rgba(26,74,116,0.72)');\n poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);\n }\n function drawCar(wx,wy,col){\n var p=iso(wx,wy), z=cam.z;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(p.x,p.y+3*z,11*z,5*z,0,0,7); X.fill();\n X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();\n X.fillStyle='rgba(255,255,255,0.25)'; X.beginPath(); X.roundRect(p.x-6*z,p.y-6*z,12*z,5*z,2*z); X.fill();\n }\n function drawPalm(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n X.fillStyle='rgba(0,0,0,0.26)'; X.beginPath(); X.ellipse(p.x,p.y,10*z,4.5*z,0,0,7); X.fill();\n X.strokeStyle='#6b4a24'; X.lineWidth=3*z; X.beginPath(); X.moveTo(p.x,p.y); X.quadraticCurveTo(p.x+3*z,p.y-14*z,p.x+1*z,p.y-24*z); X.stroke();\n var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;\n for(var a=0;a<6;a++){ var ang=(-Math.PI/2)+(a-2.5)*0.52; X.beginPath(); X.moveTo(tx,ty); X.quadraticCurveTo(tx+Math.cos(ang)*10*z, ty+Math.sin(ang)*8*z-4*z, tx+Math.cos(ang)*20*z, ty+Math.sin(ang)*11*z+3*z); X.stroke(); }\n X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();\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 padMark(bx,by,z){ X.strokeStyle='rgba(255,255,255,0.10)'; X.lineWidth=1; X.beginPath(); X.ellipse(bx,by,15*z,7*z,0,0,7); X.stroke(); }\n function drawHub(n){\n var p=iso(n.wx,n.wy), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-6*z-bob;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,16*z,7*z,0,0,7); X.fill();\n X.fillStyle='rgba(40,34,58,0.92)'; 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+16*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=iso(n.wx,n.wy), z=cam.z, bob=n.connected?Math.sin((performance.now()-t0)/500+n.wx)*2.3*z:0, bx=p.x, by=p.y-4*z-bob;\n if(n.connected) ring(bx,p.y+3*z,18*z,n.load/100,loadColor(n.load)); else padMark(bx,p.y+3*z,z);\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+3*z,13*z,6*z,0,0,7); X.fill();\n var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';\n X.globalAlpha=n.connected?1:0.7;\n X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;\n X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();\n X.beginPath(); X.roundRect(bx-8*z,by-37*z,16*z,14*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();\n X.fillStyle=n.connected?getVar('--ok'):'#655'; X.beginPath(); X.arc(bx-3.5*z,by-30*z,2*z,0,7); X.arc(bx+3.5*z,by-30*z,2*z,0,7); X.fill();\n X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();\n X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();\n X.globalAlpha=1;\n var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' \u00B7 ':'')+Math.round(n.load)+'%'):'not joined';\n label(bx,p.y+17*z,n.label,col,sub);\n n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};\n }\n function drawPeer(n){\n var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,13*z,6*z,0,0,7); X.fill();\n var col=n.connected?getVar('--info'):'rgba(150,143,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+15*z,n.label,col,(n.connected?n.sub:'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 drawGround();\n // depth-sort palms + nodes so nearer objects overlap farther ones\n var objs=[];\n for(var pi=0;pi<PALMS.length;pi++){ var w=W2(PALMS[pi][0],PALMS[pi][1]); objs.push({wx:w[0],wy:w[1],palm:true}); }\n for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }\n objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });\n objs.forEach(function(o){ if(o.palm) drawPalm(o.wx,o.wy); else { var n=o.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 o2=orbs[i]; o2.t+=0.024; if(o2.t>=1){orbs.splice(i,1);continue;}\n var a=iso(o2.a.wx,o2.a.wy), b=iso(o2.b.wx,o2.b.wy), e=o2.t<.5?2*o2.t*o2.t:-1+(4-2*o2.t)*o2.t;\n var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o2.t*Math.PI)*40*cam.z;\n X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o2.color; X.shadowColor=o2.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.wx,pu.n.wy), yy=pp.y-56*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-18*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\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 var sdkLoading=false;\n function loadSDK(){\n if(window.LivekitClient || sdkLoading) return; sdkLoading=true;\n var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;\n s.onerror=function(){ sdkLoading=false; }; document.head.appendChild(s);\n }\n function activate(){\n ensure();\n loadSDK();\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.label||n.name;\n $('pk-sub').textContent = n.connected ? (n.name+' \u00B7 '+(n.platform||'?')+' \u00B7 v'+n.version+(n.site?(' \u00B7 '+n.site):'')) : 'pad reserved \u00B7 not joined';\n var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; 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 if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n micAnalyser=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.connected){\n b.innerHTML =\n sect('\uD83C\uDD7F park pad \u00B7 '+esc(n.label), '<div class=\"pk-note\" style=\"opacity:1;color:var(--muted)\">No robot has joined this pad yet. When a node named <b>'+esc((n.telemKeys[1]||n.label).toString())+'</b> connects to the mesh, its live camera, mic, speaker, and telemetry appear here automatically.</div>')\n + sect('\uD83D\uDCCD location', '<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\">'+esc(n.label)+'</div><div class=\"l\">pad</div></div><div class=\"pk-kv\"><div class=\"v\" style=\"color:var(--muted)\">idle</div><div class=\"l\">status</div></div></div>');\n return;\n }\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){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }\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');\n camAbort = ('AbortController' in window)?new AbortController():null;\n idleCam('connecting');\n // scheduler mints a subscribe-only viewer token for the robot's current\n // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).\n fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(info && info.active===false){ idleCam(info.reason||'idle'); return; }\n if(info && info.url && info.token){\n if(window.LivekitClient){ attachLiveKit(info, n); }\n else { idleCam('no-sdk'); }\n } else { idleCam('idle'); }\n })\n .catch(function(){ idleCam('unreachable'); });\n }\n function idleCam(reason){\n var cc=$('pk-camcv'), msg={connecting:'connecting to live session\u2026', idle:'robot idle \u00B7 no live session', production_mode_off:'production mode off', 'no-sdk':'live session up \u00B7 loading video\u2026', unreachable:'scheduler unreachable'}[reason]||'no live feed';\n if(cc&&cc.getContext){ var cx=cc.getContext('2d'); cx.fillStyle='#0b0d12'; cx.fillRect(0,0,cc.width,cc.height); cx.strokeStyle='rgba(255,255,255,0.05)'; for(var y=0;y<cc.height;y+=4){cx.beginPath();cx.moveTo(0,y);cx.lineTo(cc.width,y);cx.stroke();} cx.fillStyle='#5a6a80'; cx.font='11px monospace'; cx.textAlign='center'; cx.fillText(msg, cc.width/2, cc.height/2); }\n var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'\u2014';\n var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;\n }\n function attachLiveKit(info, n){\n var note=$('pk-camnote'), res=$('pk-camres');\n try{\n var LK=window.LivekitClient; lkRoom=new LK.Room();\n lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){\n if(track.kind==='video'){\n var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;\n var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);\n if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit \u00B7 '+esc(info.room);\n } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }\n });\n lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='\u2014'; });\n lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected \u00B7 waiting for camera track\u2026'; })\n .catch(function(){ idleCam('unreachable'); });\n }catch(e){ idleCam('no-sdk'); }\n }\n // Real mic level: tap the robot's subscribed 'mic' audio track with a Web\n // Audio analyser and read RMS in startMeters (no invented levels).\n function hookMicAnalyser(track){\n try{\n var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;\n audioCtx=audioCtx||new AC();\n var ms=track.mediaStreamTrack; if(!ms) return;\n var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));\n var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;\n }catch(e){}\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?sw.getContext('2d'):null, buf=new Uint8Array(128);\n (function loop(){ if(!$('pk-micbars')||drawerId==null) return;\n // MIC \u2014 real RMS from the subscribed LiveKit mic track (else no signal).\n var lvl=0;\n if(micAnalyser){ micAnalyser.getByteTimeDomainData(buf); var sum=0; for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; } lvl=Math.min(1, Math.sqrt(sum/buf.length)*3.4); }\n for(var i=0;i<mi.length;i++){ var on=i<lvl*mi.length; mi[i].style.height=(3+(on?lvl*13:0))+'px'; mi[i].style.background=on?(i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'):'var(--glass-border)'; }\n set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'\u2014');\n set('pk-micstate', micAnalyser?(lvl>0.12?'live \u00B7 hearing audio':'live \u00B7 quiet'):'no live mic track');\n // SPEAKER \u2014 the Pi plays TTS out its own speaker; it is not published as a\n // track, so report status honestly instead of inventing a level.\n for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }\n set('pk-spkval','\u2014'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');\n if(swx){ swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle='rgba(255,255,255,0.12)'; swx.lineWidth=1.4; swx.beginPath(); for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.1)*1.1; 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>";
|
|
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/* fleet card \u2014 live per-robot hardware */\n.statdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:0.3rem;vertical-align:middle;flex:none;}\n.statdot.on{background:var(--ok);box-shadow:0 0 7px color-mix(in srgb,var(--ok) 70%,transparent);}\n.statdot.off{background:var(--bad);}\n.node .mrow{display:grid;grid-template-columns:2.3rem 1fr 2.6rem;align-items:center;gap:0.45rem;margin-top:0.42rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.node .mrow .ml{letter-spacing:0.04em;}\n.node .mrow .mv{color:var(--ink);font-size:0.66rem;text-align:right;}\n.node .mrow .mv.warn{color:var(--warn);} .node .mrow .mv.bad{color:var(--bad);} .node .mrow .mv.ok{color:var(--ok);}\n.node .mrow .bar-track{margin:0;}\n.node .spark{width:100%;height:20px;display:block;margin:0.28rem 0 0.1rem;opacity:0.92;}\n.node .stats{display:flex;flex-wrap:wrap;gap:0.3rem 0.7rem;margin-top:0.5rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}\n.node .stats span b{color:var(--ink);font-weight:600;}\n.node .stats .rx b{color:var(--info);} .node .stats .tx b{color:var(--gold-bright);}\n.tbadge{font-family:var(--mono);font-size:0.6rem;padding:0.08em 0.45em;border-radius:5px;border:1px solid var(--glass-border);color:var(--muted);}\n.tbadge.ok{color:var(--ok);} .tbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);}\n.tbadge.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}\n.chip.alert b{color:var(--bad);}\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 <button class=\"act ghost\" id=\"updateBtn\" title=\"npm-update every node to the latest release and restart (supervised nodes come back on their own)\">Update 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 // \u2500\u2500 per-node hardware store + rolling history (drives the Fleet cards + sparklines) \u2500\u2500\n var hwByNode = {}; // nodeId -> latest HardwareSnapshot (self from /fed/status.hardware, peers from 'hw' frames)\n var hwHist = {}; // nodeId -> { cpu:[], temp:[], net:[] } rolling buffers\n var HWMAX = 48; // ~2.5 min of history at the 3s poll cadence\n function netTotal(hw){\n var t=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ var f=hw.network.interfaces[i]; t+=(f.rxBytesPerSec||0)+(f.txBytesPerSec||0); } } return t;\n }\n function netRxTx(hw){\n var rx=0,tx=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ rx+=hw.network.interfaces[i].rxBytesPerSec||0; tx+=hw.network.interfaces[i].txBytesPerSec||0; } } return {rx:rx,tx:tx};\n }\n function pushHist(id, snap){\n var h = hwHist[id] || (hwHist[id]={cpu:[],temp:[],net:[]});\n var cpu = snap&&snap.cpu?Math.round(snap.cpu.percent):0;\n var temp = snap&&snap.temperatureC!=null?snap.temperatureC:null;\n h.cpu.push(cpu); if(h.cpu.length>HWMAX) h.cpu.shift();\n h.temp.push(temp); if(h.temp.length>HWMAX) h.temp.shift();\n h.net.push(netTotal(snap)); if(h.net.length>HWMAX) h.net.shift();\n }\n function updateHw(s){\n if(s.self && s.hardware){ hwByNode[s.self.nodeId] = s.hardware; }\n var act = s.activity||[];\n for(var i=0;i<act.length;i++){ var f=(act[i].f||act[i]); if(f.t==='hw'&&f.n&&f.d){ hwByNode[f.n]=f.d; } }\n var nodes = s.nodes||[];\n for(var j=0;j<nodes.length;j++){ if(nodes[j].connected) pushHist(nodes[j].nodeId, hwByNode[nodes[j].nodeId]); }\n }\n // Inline sparkline (SVG polyline) from a numeric history buffer.\n function sparkSvg(arr, color, maxv){\n if(!arr || arr.length<2) return '';\n var w=100,h=20,n=arr.length, vals=[];\n for(var i=0;i<n;i++) vals.push(arr[i]==null?0:arr[i]);\n var mx = maxv || Math.max.apply(null, vals); if(mx<=0) mx=1;\n var pts=''; for(var j=0;j<n;j++){ var x=(j/(n-1))*w; var y=h-(vals[j]/mx)*(h-2)-1; pts+=(j?' ':'')+x.toFixed(1)+','+y.toFixed(1); }\n return '<svg class=\"spark\" viewBox=\"0 0 '+w+' '+h+'\" preserveAspectRatio=\"none\" aria-hidden=\"true\">'\n + '<polyline points=\"'+pts+'\" fill=\"none\" stroke=\"'+color+'\" stroke-width=\"1.6\" stroke-linejoin=\"round\"/></svg>';\n }\n function fmtRate(b){ return fmtBytes(b)+'/s'; }\n function fmtUptime(sec){ if(sec==null) return '\u2014'; var d=Math.floor(sec/86400),h=Math.floor((sec%86400)/3600),m=Math.floor((sec%3600)/60); if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; return m+'m'; }\n function tempClass(t){ return t==null?'':(t>=75?'bad':(t>=65?'warn':'ok')); }\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 if(hw.temperatureC!=null){ html += meter('Temp', Math.min(100,(hw.temperatureC/90)*100), Math.round(hw.temperatureC)+'\u00B0C'); }\n if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]){ var mnt=hw.disk.mounts[0]; html += meter('Disk '+esc(mnt.path), mnt.percent, fmtBytes(mnt.usedBytes)+' / '+fmtBytes(mnt.totalBytes)); }\n var nt=netRxTx(hw); html += '<div class=\"stats\" style=\"margin-top:0.55rem\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span><span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (hw.system?('<span>up <b>'+fmtUptime(hw.system.uptimeSec)+'</b></span>'):'') + '</div>';\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 mrow(label, pct, valTxt, cls){\n var v = Math.max(0, Math.min(100, Math.round(pct||0)));\n return '<div class=\"mrow\"><span class=\"ml\">'+label+'</span>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div>'\n + '<span class=\"mv '+(cls||'')+'\">'+esc(valTxt)+'</span></div>';\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 // connected first, then robots/satellites above hubs so the fleet reads robot-first\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var accent = '#6FE0A6';\n try{ accent = getComputedStyle(document.documentElement).getPropertyValue('--ok').trim() || accent; }catch(e){}\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 up = !!n.connected;\n var hw = hwByNode[n.nodeId] || null;\n var hist = hwHist[n.nodeId] || {};\n var cpu = hw&&hw.cpu ? Math.round(hw.cpu.percent) : (n.load!=null?Math.round(n.load):0);\n var cores = hw&&hw.cpu ? hw.cpu.cores : null;\n var mem = hw&&hw.memory ? Math.round(hw.memory.percent) : null;\n var temp = hw&&hw.temperatureC!=null ? hw.temperatureC : null;\n var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;\n var nt = netRxTx(hw);\n var upt = hw&&hw.system ? hw.system.uptimeSec : null;\n var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);\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\n html += '<div class=\"node '+(up?'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\"><span class=\"statdot '+(up?'on':'off')+'\"></span>'+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||'')+' '+esc((hw&&hw.cpu&&hw.cpu.model)?hw.cpu.model.replace(/\\s+/g,' ').slice(0,22):'')+site+' <span class=\"mono\">v'+esc(n.version||'?')+'</span></div>';\n\n if(up){\n html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))\n + (hist.cpu&&hist.cpu.length>1 ? sparkSvg(hist.cpu, accent, 100) : '')\n + (mem!=null ? mrow('MEM', mem, mem+'%', loadClass(mem)) : '')\n + (temp!=null ? mrow('TEMP', Math.min(100,(temp/90)*100), Math.round(temp)+'\u00B0C', tempClass(temp)) : '')\n + '<div class=\"stats\">'\n + '<span class=\"rx\">\u2193 <b>'+fmtRate(nt.rx)+'</b></span>'\n + '<span class=\"tx\">\u2191 <b>'+fmtRate(nt.tx)+'</b></span>'\n + (disk!=null?('<span>disk <b>'+disk+'%</b></span>'):'')\n + (upt!=null?('<span>up <b>'+fmtUptime(upt)+'</b></span>'):'')\n + '<span><b>'+((n.commands||[]).length)+'</b> cmds</span>'\n + '</div>';\n } else {\n html += '<div class=\"stats\"><span style=\"color:var(--muted)\">offline'+(n.lastSeenAt?(' \u00B7 last seen '+clock(n.lastSeenAt)):'')+'</span></div>';\n }\n html += (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'') + '</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 // live aggregates + alerts from per-node hardware\n var hotTemp=null, maxCpu=0, alerts=[];\n for(var k=0;k<nodes.length;k++){\n var nn=nodes[k]; if(!nn.connected) continue;\n var hw=hwByNode[nn.nodeId]; if(!hw) continue;\n if(hw.cpu&&hw.cpu.percent>maxCpu) maxCpu=Math.round(hw.cpu.percent);\n if(hw.temperatureC!=null&&(hotTemp==null||hw.temperatureC>hotTemp)) hotTemp=hw.temperatureC;\n if(hw.temperatureC!=null&&hw.temperatureC>=75) alerts.push(nn.displayName+' '+Math.round(hw.temperatureC)+'\u00B0C');\n if(hw.cpu&&hw.cpu.percent>=92) alerts.push(nn.displayName+' cpu '+Math.round(hw.cpu.percent)+'%');\n if(hw.memory&&hw.memory.percent>=92) alerts.push(nn.displayName+' mem '+Math.round(hw.memory.percent)+'%');\n if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]&&hw.disk.mounts[0].percent>=90) alerts.push(nn.displayName+' disk '+hw.disk.mounts[0].percent+'%');\n }\n if(down>0) alerts.unshift(down+' offline');\n var html = '<span class=\"chip ok\">up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>'\n + '<span class=\"chip\">peak cpu <b>'+maxCpu+'%</b></span>'\n + (hotTemp!=null?('<span class=\"chip\">hottest <b>'+Math.round(hotTemp)+'\u00B0C</b></span>'):'');\n for(var a2=0;a2<alerts.length&&a2<4;a2++){ html += '<span class=\"chip alert\">\u26A0 <b>'+esc(alerts[a2])+'</b></span>'; }\n $('summary').innerHTML = html;\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 updateHw(s);\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 $('updateBtn').addEventListener('click', function(){\n if(!confirm('Update every node to the latest npm release and restart them?\\n\\nSupervised nodes relaunch automatically; the hub itself will blip while it restarts.')) return;\n $('updateBtn').disabled = true;\n showOut('Updating fleet \u2014 installing latest on each node\u2026');\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })\n .catch(function(e){ showOut('update failed: '+e.message); })\n .then(function(){ $('updateBtn').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}, fitted=false;\n var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;\n var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;\n var lkRoom=null, micAnalyser=null, audioCtx=null;\n var TW=44, TH=22, PARK_W=15, PARK_D=8;\n // The real park (from the aerial): named robot pads at their ground\n // positions, plus palms, pond, and the street. A mesh node binds to a pad\n // when its display name matches the pad keywords (robobmw \u2192 VIXEN/BMW).\n var SLOTS=[\n {label:'VOLT', keys:['volt'], nx:0.30, ny:0.22},\n {label:'TESLA', keys:['tesla'], nx:0.44, ny:0.36},\n {label:'JAGUAR', keys:['jaguar','jag'], nx:0.60, ny:0.40},\n {label:'VIXEN', keys:['vixen','bmw'], nx:0.55, ny:0.66, sub:'BMW'},\n {label:'PANDA', keys:['panda'], nx:0.84, ny:0.58}\n ];\n var HUBSLOT={label:'CONTROL', keys:[], nx:0.09, ny:0.12};\n var PALMS=[[0.11,0.55],[0.15,0.74],[0.19,0.42],[0.23,0.64],[0.17,0.31],[0.25,0.82],[0.37,0.24],[0.41,0.56],[0.49,0.74],[0.52,0.29],[0.59,0.62],[0.66,0.26],[0.71,0.69],[0.76,0.42],[0.82,0.74],[0.89,0.42],[0.92,0.68],[0.67,0.83],[0.34,0.71]];\n var POND={cx:0.33*15, cy:0.99*8, rx:3.1, ry:1.4};\n var CARS=[[4,-0.95,'#e0a23c'],[6,-0.8,'#7fb4ff'],[8,-1.0,'#ff7e6e'],[10.5,-0.78,'#d8d8e0'],[12.8,-0.95,'#f2c14e']];\n\n function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }\n function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }\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 // Bind mesh nodes onto the fixed park pads; unmatched pads render as\n // offline placeholders so the whole park is always visible.\n function layout(src){\n var list=(src||[]).slice(), used={}, out=[];\n function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }\n function bind(slot, wx, wy, kind){\n var match=null;\n for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;\n var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';\n if(kind==='hub'){ if(isHub){ match=n; break; } }\n else if(!isHub){ for(var k=0;k<slot.keys.length;k++){ if(slot.keys[k] && nm.indexOf(slot.keys[k])>-1){ match=n; break; } } if(match) break; }\n }\n var id = match?match.nodeId:('pad:'+slot.label);\n if(match) used[match.nodeId]=1;\n return {\n id:id, kind:kind, label:slot.label,\n name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),\n sub: match?(slot.sub||''):'not joined',\n connected: match?!!match.connected:false,\n load: (match&&match.load!=null)?match.load:0,\n platform: match?(match.platform||''):'', version: match?(match.version||'?'):'\u2014',\n site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],\n telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),\n wx:wx, wy:wy, _hit:prevHit(id)\n };\n }\n var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));\n SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });\n // Any extra live node that matched no pad \u2192 line it along the near edge.\n var ex=0;\n list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;\n var w=W2(0.20+ex*0.13, 0.90); ex++;\n out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),\n sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',\n site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });\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==null?'':String(f.n)).toLowerCase(); if(!key) return null;\n for(var i=0;i<nodes.length;i++){ var n=nodes[i];\n if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;\n for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }\n }\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); if(String(hit.id).indexOf('pad:')!==0) 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 if(!fitted){ fit(); fitted=true; }\n }\n function fit(){\n var span=PARK_W+PARK_D;\n var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);\n cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;\n }\n // World (park) coords \u2192 screen. Origin biased left so the right-docked feed\n // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).\n function iso(wx,wy){\n var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);\n return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };\n }\n function poly(pts, fill, stroke, lw){\n X.beginPath();\n for(var i=0;i<pts.length;i++){ var p=iso(pts[i][0],pts[i][1]); if(i===0)X.moveTo(p.x,p.y); else X.lineTo(p.x,p.y); }\n X.closePath();\n if(fill){ X.fillStyle=fill; X.fill(); }\n if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }\n }\n function drawGround(){\n // grass base\n var g=X.createLinearGradient(0,0,0,H); g.addColorStop(0,'rgba(38,68,44,0.55)'); g.addColorStop(1,'rgba(22,44,28,0.5)');\n poly([[-2,-2.2],[PARK_W+2,-2.2],[PARK_W+2,PARK_D+2],[-2,PARK_D+2]], g, 'rgba(120,180,120,0.10)',1);\n // street band along the far edge + dashed centerline + cars\n poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.88)', 'rgba(255,255,255,0.08)',1);\n X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);\n var r1=iso(0.2,-0.92), r2=iso(PARK_W-0.2,-0.92); X.beginPath(); X.moveTo(r1.x,r1.y); X.lineTo(r2.x,r2.y); X.stroke(); X.setLineDash([]);\n CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });\n // paved plaza\n var px0=0.4,py0=-0.15,px1=PARK_W-0.4,py1=PARK_D-0.6;\n var pg=X.createLinearGradient(0,0,0,H); pg.addColorStop(0,'rgba(74,70,92,0.66)'); pg.addColorStop(1,'rgba(46,42,60,0.62)');\n poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);\n X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;\n for(var gx=Math.ceil(px0);gx<px1;gx++){ var a=iso(gx,py0), b=iso(gx,py1); X.beginPath(); X.moveTo(a.x,a.y); X.lineTo(b.x,b.y); X.stroke(); }\n for(var gy=Math.ceil(py0);gy<py1;gy++){ var c=iso(px0,gy), d=iso(px1,gy); X.beginPath(); X.moveTo(c.x,c.y); X.lineTo(d.x,d.y); X.stroke(); }\n // pond\n var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/9){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)]); }\n var wg=X.createLinearGradient(0,0,0,H); wg.addColorStop(0,'rgba(44,116,156,0.72)'); wg.addColorStop(1,'rgba(26,74,116,0.72)');\n poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);\n }\n function drawCar(wx,wy,col){\n var p=iso(wx,wy), z=cam.z;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(p.x,p.y+3*z,11*z,5*z,0,0,7); X.fill();\n X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();\n X.fillStyle='rgba(255,255,255,0.25)'; X.beginPath(); X.roundRect(p.x-6*z,p.y-6*z,12*z,5*z,2*z); X.fill();\n }\n function drawPalm(wx,wy){\n var p=iso(wx,wy), z=cam.z;\n X.fillStyle='rgba(0,0,0,0.26)'; X.beginPath(); X.ellipse(p.x,p.y,10*z,4.5*z,0,0,7); X.fill();\n X.strokeStyle='#6b4a24'; X.lineWidth=3*z; X.beginPath(); X.moveTo(p.x,p.y); X.quadraticCurveTo(p.x+3*z,p.y-14*z,p.x+1*z,p.y-24*z); X.stroke();\n var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;\n for(var a=0;a<6;a++){ var ang=(-Math.PI/2)+(a-2.5)*0.52; X.beginPath(); X.moveTo(tx,ty); X.quadraticCurveTo(tx+Math.cos(ang)*10*z, ty+Math.sin(ang)*8*z-4*z, tx+Math.cos(ang)*20*z, ty+Math.sin(ang)*11*z+3*z); X.stroke(); }\n X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();\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 padMark(bx,by,z){ X.strokeStyle='rgba(255,255,255,0.10)'; X.lineWidth=1; X.beginPath(); X.ellipse(bx,by,15*z,7*z,0,0,7); X.stroke(); }\n function drawHub(n){\n var p=iso(n.wx,n.wy), z=cam.z, bob=Math.sin((performance.now()-t0)/700)*2*z, bx=p.x, by=p.y-6*z-bob;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,16*z,7*z,0,0,7); X.fill();\n X.fillStyle='rgba(40,34,58,0.92)'; 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+16*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=iso(n.wx,n.wy), z=cam.z, bob=n.connected?Math.sin((performance.now()-t0)/500+n.wx)*2.3*z:0, bx=p.x, by=p.y-4*z-bob;\n if(n.connected) ring(bx,p.y+3*z,18*z,n.load/100,loadColor(n.load)); else padMark(bx,p.y+3*z,z);\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+3*z,13*z,6*z,0,0,7); X.fill();\n var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';\n X.globalAlpha=n.connected?1:0.7;\n X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;\n X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();\n X.beginPath(); X.roundRect(bx-8*z,by-37*z,16*z,14*z,4*z); X.fillStyle='rgba(30,24,44,0.95)'; X.fill(); X.stroke();\n X.fillStyle=n.connected?getVar('--ok'):'#655'; X.beginPath(); X.arc(bx-3.5*z,by-30*z,2*z,0,7); X.arc(bx+3.5*z,by-30*z,2*z,0,7); X.fill();\n X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();\n X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();\n X.globalAlpha=1;\n var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' \u00B7 ':'')+Math.round(n.load)+'%'):'not joined';\n label(bx,p.y+17*z,n.label,col,sub);\n n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};\n }\n function drawPeer(n){\n var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;\n X.fillStyle='rgba(0,0,0,0.3)'; X.beginPath(); X.ellipse(bx,p.y+2*z,13*z,6*z,0,0,7); X.fill();\n var col=n.connected?getVar('--info'):'rgba(150,143,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+15*z,n.label,col,(n.connected?n.sub:'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 drawGround();\n // depth-sort palms + nodes so nearer objects overlap farther ones\n var objs=[];\n for(var pi=0;pi<PALMS.length;pi++){ var w=W2(PALMS[pi][0],PALMS[pi][1]); objs.push({wx:w[0],wy:w[1],palm:true}); }\n for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }\n objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });\n objs.forEach(function(o){ if(o.palm) drawPalm(o.wx,o.wy); else { var n=o.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 o2=orbs[i]; o2.t+=0.024; if(o2.t>=1){orbs.splice(i,1);continue;}\n var a=iso(o2.a.wx,o2.a.wy), b=iso(o2.b.wx,o2.b.wy), e=o2.t<.5?2*o2.t*o2.t:-1+(4-2*o2.t)*o2.t;\n var x=a.x+(b.x-a.x)*e, y=a.y+(b.y-a.y)*e-Math.sin(o2.t*Math.PI)*40*cam.z;\n X.beginPath(); X.arc(x,y,4*cam.z,0,7); X.fillStyle=o2.color; X.shadowColor=o2.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.wx,pu.n.wy), yy=pp.y-56*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-18*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\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 var sdkLoading=false;\n function loadSDK(){\n if(window.LivekitClient || sdkLoading) return; sdkLoading=true;\n var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;\n s.onerror=function(){ sdkLoading=false; }; document.head.appendChild(s);\n }\n function activate(){\n ensure();\n loadSDK();\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.label||n.name;\n $('pk-sub').textContent = n.connected ? (n.name+' \u00B7 '+(n.platform||'?')+' \u00B7 v'+n.version+(n.site?(' \u00B7 '+n.site):'')) : 'pad reserved \u00B7 not joined';\n var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; 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 if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }\n micAnalyser=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.connected){\n b.innerHTML =\n sect('\uD83C\uDD7F park pad \u00B7 '+esc(n.label), '<div class=\"pk-note\" style=\"opacity:1;color:var(--muted)\">No robot has joined this pad yet. When a node named <b>'+esc((n.telemKeys[1]||n.label).toString())+'</b> connects to the mesh, its live camera, mic, speaker, and telemetry appear here automatically.</div>')\n + sect('\uD83D\uDCCD location', '<div class=\"pk-grid2\"><div class=\"pk-kv\"><div class=\"v\">'+esc(n.label)+'</div><div class=\"l\">pad</div></div><div class=\"pk-kv\"><div class=\"v\" style=\"color:var(--muted)\">idle</div><div class=\"l\">status</div></div></div>');\n return;\n }\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){ var keys=n.telemKeys||[]; for(var rk in roboparkByName){ var lk=rk.toLowerCase(); for(var ki=0;ki<keys.length;ki++){ if(keys[ki] && (lk===keys[ki]||lk.indexOf(keys[ki])>-1)){ row=roboparkByName[rk]; break; } } if(row) break; } }\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');\n camAbort = ('AbortController' in window)?new AbortController():null;\n idleCam('connecting');\n // scheduler mints a subscribe-only viewer token for the robot's current\n // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).\n fetch('/robopark/api/robots/'+encodeURIComponent(n.name)+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})\n .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })\n .then(function(info){\n if(info && info.active===false){ idleCam(info.reason||'idle'); return; }\n if(info && info.url && info.token){\n if(window.LivekitClient){ attachLiveKit(info, n); }\n else { idleCam('no-sdk'); }\n } else { idleCam('idle'); }\n })\n .catch(function(){ idleCam('unreachable'); });\n }\n function idleCam(reason){\n var cc=$('pk-camcv'), msg={connecting:'connecting to live session\u2026', idle:'robot idle \u00B7 no live session', production_mode_off:'production mode off', 'no-sdk':'live session up \u00B7 loading video\u2026', unreachable:'scheduler unreachable'}[reason]||'no live feed';\n if(cc&&cc.getContext){ var cx=cc.getContext('2d'); cx.fillStyle='#0b0d12'; cx.fillRect(0,0,cc.width,cc.height); cx.strokeStyle='rgba(255,255,255,0.05)'; for(var y=0;y<cc.height;y+=4){cx.beginPath();cx.moveTo(0,y);cx.lineTo(cc.width,y);cx.stroke();} cx.fillStyle='#5a6a80'; cx.font='11px monospace'; cx.textAlign='center'; cx.fillText(msg, cc.width/2, cc.height/2); }\n var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'\u2014';\n var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;\n }\n function attachLiveKit(info, n){\n var note=$('pk-camnote'), res=$('pk-camres');\n try{\n var LK=window.LivekitClient; lkRoom=new LK.Room();\n lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){\n if(track.kind==='video'){\n var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;\n var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);\n if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit \u00B7 '+esc(info.room);\n } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }\n });\n lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='\u2014'; });\n lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected \u00B7 waiting for camera track\u2026'; })\n .catch(function(){ idleCam('unreachable'); });\n }catch(e){ idleCam('no-sdk'); }\n }\n // Real mic level: tap the robot's subscribed 'mic' audio track with a Web\n // Audio analyser and read RMS in startMeters (no invented levels).\n function hookMicAnalyser(track){\n try{\n var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;\n audioCtx=audioCtx||new AC();\n var ms=track.mediaStreamTrack; if(!ms) return;\n var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));\n var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;\n }catch(e){}\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?sw.getContext('2d'):null, buf=new Uint8Array(128);\n (function loop(){ if(!$('pk-micbars')||drawerId==null) return;\n // MIC \u2014 real RMS from the subscribed LiveKit mic track (else no signal).\n var lvl=0;\n if(micAnalyser){ micAnalyser.getByteTimeDomainData(buf); var sum=0; for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; } lvl=Math.min(1, Math.sqrt(sum/buf.length)*3.4); }\n for(var i=0;i<mi.length;i++){ var on=i<lvl*mi.length; mi[i].style.height=(3+(on?lvl*13:0))+'px'; mi[i].style.background=on?(i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'):'var(--glass-border)'; }\n set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'\u2014');\n set('pk-micstate', micAnalyser?(lvl>0.12?'live \u00B7 hearing audio':'live \u00B7 quiet'):'no live mic track');\n // SPEAKER \u2014 the Pi plays TTS out its own speaker; it is not published as a\n // track, so report status honestly instead of inventing a level.\n for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }\n set('pk-spkval','\u2014'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');\n if(swx){ swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle='rgba(255,255,255,0.12)'; swx.lineWidth=1.4; swx.beginPath(); for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.1)*1.1; 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>";
|
|
@@ -124,6 +124,25 @@ section h2 .count{color:var(--muted);}
|
|
|
124
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
125
|
.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}
|
|
126
126
|
|
|
127
|
+
/* fleet card — live per-robot hardware */
|
|
128
|
+
.statdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:0.3rem;vertical-align:middle;flex:none;}
|
|
129
|
+
.statdot.on{background:var(--ok);box-shadow:0 0 7px color-mix(in srgb,var(--ok) 70%,transparent);}
|
|
130
|
+
.statdot.off{background:var(--bad);}
|
|
131
|
+
.node .mrow{display:grid;grid-template-columns:2.3rem 1fr 2.6rem;align-items:center;gap:0.45rem;margin-top:0.42rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
|
|
132
|
+
.node .mrow .ml{letter-spacing:0.04em;}
|
|
133
|
+
.node .mrow .mv{color:var(--ink);font-size:0.66rem;text-align:right;}
|
|
134
|
+
.node .mrow .mv.warn{color:var(--warn);} .node .mrow .mv.bad{color:var(--bad);} .node .mrow .mv.ok{color:var(--ok);}
|
|
135
|
+
.node .mrow .bar-track{margin:0;}
|
|
136
|
+
.node .spark{width:100%;height:20px;display:block;margin:0.28rem 0 0.1rem;opacity:0.92;}
|
|
137
|
+
.node .stats{display:flex;flex-wrap:wrap;gap:0.3rem 0.7rem;margin-top:0.5rem;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
|
|
138
|
+
.node .stats span b{color:var(--ink);font-weight:600;}
|
|
139
|
+
.node .stats .rx b{color:var(--info);} .node .stats .tx b{color:var(--gold-bright);}
|
|
140
|
+
.tbadge{font-family:var(--mono);font-size:0.6rem;padding:0.08em 0.45em;border-radius:5px;border:1px solid var(--glass-border);color:var(--muted);}
|
|
141
|
+
.tbadge.ok{color:var(--ok);} .tbadge.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent);}
|
|
142
|
+
.tbadge.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}
|
|
143
|
+
.chip.alert{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent);background:var(--bad-bg);}
|
|
144
|
+
.chip.alert b{color:var(--bad);}
|
|
145
|
+
|
|
127
146
|
/* hardware meters */
|
|
128
147
|
.hw{border-radius:13px;padding:0.85rem;margin-top:1rem;
|
|
129
148
|
background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
|
|
@@ -443,6 +462,45 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
443
462
|
+ '<div class="bar-track"><div class="bar-fill '+loadClass(v)+'" style="width:'+v+'%"></div></div></div>';
|
|
444
463
|
}
|
|
445
464
|
|
|
465
|
+
// ── per-node hardware store + rolling history (drives the Fleet cards + sparklines) ──
|
|
466
|
+
var hwByNode = {}; // nodeId -> latest HardwareSnapshot (self from /fed/status.hardware, peers from 'hw' frames)
|
|
467
|
+
var hwHist = {}; // nodeId -> { cpu:[], temp:[], net:[] } rolling buffers
|
|
468
|
+
var HWMAX = 48; // ~2.5 min of history at the 3s poll cadence
|
|
469
|
+
function netTotal(hw){
|
|
470
|
+
var t=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ var f=hw.network.interfaces[i]; t+=(f.rxBytesPerSec||0)+(f.txBytesPerSec||0); } } return t;
|
|
471
|
+
}
|
|
472
|
+
function netRxTx(hw){
|
|
473
|
+
var rx=0,tx=0; if(hw&&hw.network&&hw.network.interfaces){ for(var i=0;i<hw.network.interfaces.length;i++){ rx+=hw.network.interfaces[i].rxBytesPerSec||0; tx+=hw.network.interfaces[i].txBytesPerSec||0; } } return {rx:rx,tx:tx};
|
|
474
|
+
}
|
|
475
|
+
function pushHist(id, snap){
|
|
476
|
+
var h = hwHist[id] || (hwHist[id]={cpu:[],temp:[],net:[]});
|
|
477
|
+
var cpu = snap&&snap.cpu?Math.round(snap.cpu.percent):0;
|
|
478
|
+
var temp = snap&&snap.temperatureC!=null?snap.temperatureC:null;
|
|
479
|
+
h.cpu.push(cpu); if(h.cpu.length>HWMAX) h.cpu.shift();
|
|
480
|
+
h.temp.push(temp); if(h.temp.length>HWMAX) h.temp.shift();
|
|
481
|
+
h.net.push(netTotal(snap)); if(h.net.length>HWMAX) h.net.shift();
|
|
482
|
+
}
|
|
483
|
+
function updateHw(s){
|
|
484
|
+
if(s.self && s.hardware){ hwByNode[s.self.nodeId] = s.hardware; }
|
|
485
|
+
var act = s.activity||[];
|
|
486
|
+
for(var i=0;i<act.length;i++){ var f=(act[i].f||act[i]); if(f.t==='hw'&&f.n&&f.d){ hwByNode[f.n]=f.d; } }
|
|
487
|
+
var nodes = s.nodes||[];
|
|
488
|
+
for(var j=0;j<nodes.length;j++){ if(nodes[j].connected) pushHist(nodes[j].nodeId, hwByNode[nodes[j].nodeId]); }
|
|
489
|
+
}
|
|
490
|
+
// Inline sparkline (SVG polyline) from a numeric history buffer.
|
|
491
|
+
function sparkSvg(arr, color, maxv){
|
|
492
|
+
if(!arr || arr.length<2) return '';
|
|
493
|
+
var w=100,h=20,n=arr.length, vals=[];
|
|
494
|
+
for(var i=0;i<n;i++) vals.push(arr[i]==null?0:arr[i]);
|
|
495
|
+
var mx = maxv || Math.max.apply(null, vals); if(mx<=0) mx=1;
|
|
496
|
+
var pts=''; for(var j=0;j<n;j++){ var x=(j/(n-1))*w; var y=h-(vals[j]/mx)*(h-2)-1; pts+=(j?' ':'')+x.toFixed(1)+','+y.toFixed(1); }
|
|
497
|
+
return '<svg class="spark" viewBox="0 0 '+w+' '+h+'" preserveAspectRatio="none" aria-hidden="true">'
|
|
498
|
+
+ '<polyline points="'+pts+'" fill="none" stroke="'+color+'" stroke-width="1.6" stroke-linejoin="round"/></svg>';
|
|
499
|
+
}
|
|
500
|
+
function fmtRate(b){ return fmtBytes(b)+'/s'; }
|
|
501
|
+
function fmtUptime(sec){ if(sec==null) return '—'; var d=Math.floor(sec/86400),h=Math.floor((sec%86400)/3600),m=Math.floor((sec%3600)/60); if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; return m+'m'; }
|
|
502
|
+
function tempClass(t){ return t==null?'':(t>=75?'bad':(t>=65?'warn':'ok')); }
|
|
503
|
+
|
|
446
504
|
function renderHw(hw){
|
|
447
505
|
var el = $('hw');
|
|
448
506
|
if(!hw){ el.innerHTML = '<div class="meter"><div class="lab"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }
|
|
@@ -450,6 +508,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
450
508
|
html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');
|
|
451
509
|
html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');
|
|
452
510
|
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'):''); }
|
|
511
|
+
if(hw.temperatureC!=null){ html += meter('Temp', Math.min(100,(hw.temperatureC/90)*100), Math.round(hw.temperatureC)+'°C'); }
|
|
512
|
+
if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]){ var mnt=hw.disk.mounts[0]; html += meter('Disk '+esc(mnt.path), mnt.percent, fmtBytes(mnt.usedBytes)+' / '+fmtBytes(mnt.totalBytes)); }
|
|
513
|
+
var nt=netRxTx(hw); html += '<div class="stats" style="margin-top:0.55rem">'
|
|
514
|
+
+ '<span class="rx">↓ <b>'+fmtRate(nt.rx)+'</b></span><span class="tx">↑ <b>'+fmtRate(nt.tx)+'</b></span>'
|
|
515
|
+
+ (hw.system?('<span>up <b>'+fmtUptime(hw.system.uptimeSec)+'</b></span>'):'') + '</div>';
|
|
453
516
|
el.innerHTML = html;
|
|
454
517
|
}
|
|
455
518
|
|
|
@@ -460,25 +523,57 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
460
523
|
for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);
|
|
461
524
|
}
|
|
462
525
|
|
|
526
|
+
function mrow(label, pct, valTxt, cls){
|
|
527
|
+
var v = Math.max(0, Math.min(100, Math.round(pct||0)));
|
|
528
|
+
return '<div class="mrow"><span class="ml">'+label+'</span>'
|
|
529
|
+
+ '<div class="bar-track"><div class="bar-fill '+loadClass(v)+'" style="width:'+v+'%"></div></div>'
|
|
530
|
+
+ '<span class="mv '+(cls||'')+'">'+esc(valTxt)+'</span></div>';
|
|
531
|
+
}
|
|
463
532
|
function renderFleet(nodes){
|
|
464
533
|
var el = $('fleet');
|
|
465
534
|
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; }
|
|
535
|
+
// connected first, then robots/satellites above hubs so the fleet reads robot-first
|
|
466
536
|
nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });
|
|
537
|
+
var accent = '#6FE0A6';
|
|
538
|
+
try{ accent = getComputedStyle(document.documentElement).getPropertyValue('--ok').trim() || accent; }catch(e){}
|
|
467
539
|
var html = '';
|
|
468
540
|
for(var i=0;i<nodes.length;i++){
|
|
469
541
|
var n = nodes[i];
|
|
470
542
|
var isSelf = n.nodeId===selfId;
|
|
471
|
-
var
|
|
472
|
-
var
|
|
543
|
+
var up = !!n.connected;
|
|
544
|
+
var hw = hwByNode[n.nodeId] || null;
|
|
545
|
+
var hist = hwHist[n.nodeId] || {};
|
|
546
|
+
var cpu = hw&&hw.cpu ? Math.round(hw.cpu.percent) : (n.load!=null?Math.round(n.load):0);
|
|
547
|
+
var cores = hw&&hw.cpu ? hw.cpu.cores : null;
|
|
548
|
+
var mem = hw&&hw.memory ? Math.round(hw.memory.percent) : null;
|
|
549
|
+
var temp = hw&&hw.temperatureC!=null ? hw.temperatureC : null;
|
|
550
|
+
var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;
|
|
551
|
+
var nt = netRxTx(hw);
|
|
552
|
+
var upt = hw&&hw.system ? hw.system.uptimeSec : null;
|
|
553
|
+
var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);
|
|
473
554
|
var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class="cap">'+esc(caps[c])+'</span>';
|
|
474
|
-
var site = n.site?('· '+esc(n.site)):'';
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
+ '<div class="
|
|
478
|
-
+ '<div class="
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
+ '
|
|
555
|
+
var site = n.site?(' · '+esc(n.site)):'';
|
|
556
|
+
|
|
557
|
+
html += '<div class="node '+(up?'up':'down')+(selected===n.nodeId?' sel':'')+'" tabindex="0" role="button" data-id="'+esc(n.nodeId)+'" data-name="'+esc(n.displayName)+'">'
|
|
558
|
+
+ '<div class="top"><span class="name"><span class="statdot '+(up?'on':'off')+'"></span>'+esc(n.displayName)+(isSelf?' <span class="selfbadge">this</span>':'')+'</span><span class="role">'+esc(n.role)+'</span></div>'
|
|
559
|
+
+ '<div class="meta">'+esc(n.platform||'')+' '+esc((hw&&hw.cpu&&hw.cpu.model)?hw.cpu.model.replace(/\\s+/g,' ').slice(0,22):'')+site+' <span class="mono">v'+esc(n.version||'?')+'</span></div>';
|
|
560
|
+
|
|
561
|
+
if(up){
|
|
562
|
+
html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))
|
|
563
|
+
+ (hist.cpu&&hist.cpu.length>1 ? sparkSvg(hist.cpu, accent, 100) : '')
|
|
564
|
+
+ (mem!=null ? mrow('MEM', mem, mem+'%', loadClass(mem)) : '')
|
|
565
|
+
+ (temp!=null ? mrow('TEMP', Math.min(100,(temp/90)*100), Math.round(temp)+'°C', tempClass(temp)) : '')
|
|
566
|
+
+ '<div class="stats">'
|
|
567
|
+
+ '<span class="rx">↓ <b>'+fmtRate(nt.rx)+'</b></span>'
|
|
568
|
+
+ '<span class="tx">↑ <b>'+fmtRate(nt.tx)+'</b></span>'
|
|
569
|
+
+ (disk!=null?('<span>disk <b>'+disk+'%</b></span>'):'')
|
|
570
|
+
+ (upt!=null?('<span>up <b>'+fmtUptime(upt)+'</b></span>'):'')
|
|
571
|
+
+ '<span><b>'+((n.commands||[]).length)+'</b> cmds</span>'
|
|
572
|
+
+ '</div>';
|
|
573
|
+
} else {
|
|
574
|
+
html += '<div class="stats"><span style="color:var(--muted)">offline'+(n.lastSeenAt?(' · last seen '+clock(n.lastSeenAt)):'')+'</span></div>';
|
|
575
|
+
}
|
|
576
|
+
html += (capHtml?('<div class="caps">'+capHtml+'</div>'):'') + '</div>';
|
|
482
577
|
}
|
|
483
578
|
el.innerHTML = html;
|
|
484
579
|
var cards = el.querySelectorAll('.node');
|
|
@@ -498,11 +593,27 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
498
593
|
var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;
|
|
499
594
|
var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});
|
|
500
595
|
var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;
|
|
501
|
-
|
|
502
|
-
|
|
596
|
+
// live aggregates + alerts from per-node hardware
|
|
597
|
+
var hotTemp=null, maxCpu=0, alerts=[];
|
|
598
|
+
for(var k=0;k<nodes.length;k++){
|
|
599
|
+
var nn=nodes[k]; if(!nn.connected) continue;
|
|
600
|
+
var hw=hwByNode[nn.nodeId]; if(!hw) continue;
|
|
601
|
+
if(hw.cpu&&hw.cpu.percent>maxCpu) maxCpu=Math.round(hw.cpu.percent);
|
|
602
|
+
if(hw.temperatureC!=null&&(hotTemp==null||hw.temperatureC>hotTemp)) hotTemp=hw.temperatureC;
|
|
603
|
+
if(hw.temperatureC!=null&&hw.temperatureC>=75) alerts.push(nn.displayName+' '+Math.round(hw.temperatureC)+'°C');
|
|
604
|
+
if(hw.cpu&&hw.cpu.percent>=92) alerts.push(nn.displayName+' cpu '+Math.round(hw.cpu.percent)+'%');
|
|
605
|
+
if(hw.memory&&hw.memory.percent>=92) alerts.push(nn.displayName+' mem '+Math.round(hw.memory.percent)+'%');
|
|
606
|
+
if(hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]&&hw.disk.mounts[0].percent>=90) alerts.push(nn.displayName+' disk '+hw.disk.mounts[0].percent+'%');
|
|
607
|
+
}
|
|
608
|
+
if(down>0) alerts.unshift(down+' offline');
|
|
609
|
+
var html = '<span class="chip ok">up <b>'+up+'</b></span>'
|
|
503
610
|
+ '<span class="chip'+(down?' bad':'')+'">down <b>'+down+'</b></span>'
|
|
504
|
-
+ '<span class="chip info">
|
|
505
|
-
+ '<span class="chip">avg load <b>'+avg+'%</b></span>'
|
|
611
|
+
+ '<span class="chip info">runs <b>'+runs+'</b></span>'
|
|
612
|
+
+ '<span class="chip">avg load <b>'+avg+'%</b></span>'
|
|
613
|
+
+ '<span class="chip">peak cpu <b>'+maxCpu+'%</b></span>'
|
|
614
|
+
+ (hotTemp!=null?('<span class="chip">hottest <b>'+Math.round(hotTemp)+'°C</b></span>'):'');
|
|
615
|
+
for(var a2=0;a2<alerts.length&&a2<4;a2++){ html += '<span class="chip alert">⚠ <b>'+esc(alerts[a2])+'</b></span>'; }
|
|
616
|
+
$('summary').innerHTML = html;
|
|
506
617
|
$('fleetcount').textContent = '(' + nodes.length + ')';
|
|
507
618
|
var self = s.self||{};
|
|
508
619
|
var role = s.role||{};
|
|
@@ -522,6 +633,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
522
633
|
everOk = true; setLive(true); lastStatus = s;
|
|
523
634
|
if(!selfId && s.self) selfId = s.self.nodeId;
|
|
524
635
|
if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
|
|
636
|
+
updateHw(s);
|
|
525
637
|
renderSummary(s);
|
|
526
638
|
renderFleet(s.nodes||[]);
|
|
527
639
|
renderHw(s.hardware);
|
|
@@ -7,6 +7,11 @@ export declare class TelemetryCollector {
|
|
|
7
7
|
/** Per-core % from a single snapshot (approximate, no per-core delta kept). */
|
|
8
8
|
private perCore;
|
|
9
9
|
private network;
|
|
10
|
+
/** Usage of the primary filesystem (root on POSIX, system drive on Windows) via
|
|
11
|
+
* fs.statfs (Node ≥18.15, no native deps). Never throws. */
|
|
12
|
+
private diskUsage;
|
|
13
|
+
/** CPU temperature in °C from the Linux thermal zone (Pi/SBC). null elsewhere. */
|
|
14
|
+
private temperatureC;
|
|
10
15
|
collect(): HardwareSnapshot;
|
|
11
16
|
}
|
|
12
17
|
/** A 0–100 load figure derived from a snapshot (used by the compute router). */
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* on a fixed cadence (~1 Hz) so deltas are meaningful.
|
|
10
10
|
*/
|
|
11
11
|
import os from 'node:os';
|
|
12
|
+
import { readFileSync, statfsSync } from 'node:fs';
|
|
12
13
|
export class TelemetryCollector {
|
|
13
14
|
lastCpu = null;
|
|
14
15
|
lastNet = new Map();
|
|
@@ -44,22 +45,83 @@ export class TelemetryCollector {
|
|
|
44
45
|
});
|
|
45
46
|
}
|
|
46
47
|
network() {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
48
|
+
// Real rx/tx byte-rate deltas where the kernel exposes counters. Linux lists
|
|
49
|
+
// per-interface byte totals in /proc/net/dev; we diff against the previous
|
|
50
|
+
// tick. On platforms without it (e.g. Windows) rates stay 0 but interface
|
|
51
|
+
// presence is still reported. Never throws — a read failure degrades to the
|
|
52
|
+
// os.networkInterfaces() presence list with zeroed rates.
|
|
53
|
+
const now = Date.now();
|
|
50
54
|
const out = [];
|
|
55
|
+
let counters = null;
|
|
56
|
+
if (os.platform() === 'linux') {
|
|
57
|
+
try {
|
|
58
|
+
counters = new Map();
|
|
59
|
+
for (const line of readFileSync('/proc/net/dev', 'utf8').split('\n')) {
|
|
60
|
+
const m = line.match(/^\s*([^:]+):\s*(.+)$/);
|
|
61
|
+
if (!m)
|
|
62
|
+
continue;
|
|
63
|
+
const cols = m[2].trim().split(/\s+/).map(Number);
|
|
64
|
+
// Receive bytes = column 0, Transmit bytes = column 8.
|
|
65
|
+
counters.set(m[1].trim(), { rx: cols[0] || 0, tx: cols[8] || 0 });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
counters = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const ifaces = os.networkInterfaces();
|
|
51
73
|
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
52
74
|
if (!addrs || addrs.every(a => a.internal))
|
|
53
75
|
continue;
|
|
54
76
|
const prev = this.lastNet.get(name);
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
const cur = counters?.get(name);
|
|
78
|
+
let rxRate = 0;
|
|
79
|
+
let txRate = 0;
|
|
80
|
+
if (cur && prev && prev.at) {
|
|
81
|
+
const dt = (now - prev.at) / 1000;
|
|
82
|
+
if (dt > 0) {
|
|
83
|
+
rxRate = Math.max(0, Math.round((cur.rx - prev.rx) / dt));
|
|
84
|
+
txRate = Math.max(0, Math.round((cur.tx - prev.tx) / dt));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
out.push({ name, rxBytesPerSec: rxRate, txBytesPerSec: txRate });
|
|
88
|
+
this.lastNet.set(name, { rx: cur?.rx ?? 0, tx: cur?.tx ?? 0, at: now });
|
|
60
89
|
}
|
|
61
90
|
return { interfaces: out };
|
|
62
91
|
}
|
|
92
|
+
/** Usage of the primary filesystem (root on POSIX, system drive on Windows) via
|
|
93
|
+
* fs.statfs (Node ≥18.15, no native deps). Never throws. */
|
|
94
|
+
diskUsage() {
|
|
95
|
+
const path = os.platform() === 'win32'
|
|
96
|
+
? (process.env.SystemDrive ? process.env.SystemDrive + '\\' : 'C:\\')
|
|
97
|
+
: '/';
|
|
98
|
+
try {
|
|
99
|
+
const st = statfsSync(path);
|
|
100
|
+
const totalBytes = Number(st.blocks) * Number(st.bsize);
|
|
101
|
+
const freeBytes = Number(st.bavail) * Number(st.bsize);
|
|
102
|
+
const usedBytes = Math.max(0, totalBytes - freeBytes);
|
|
103
|
+
const percent = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0;
|
|
104
|
+
return { mounts: [{ path, totalBytes, usedBytes, percent }] };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { mounts: [] };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** CPU temperature in °C from the Linux thermal zone (Pi/SBC). null elsewhere. */
|
|
111
|
+
temperatureC() {
|
|
112
|
+
if (os.platform() !== 'linux')
|
|
113
|
+
return null;
|
|
114
|
+
try {
|
|
115
|
+
const milli = Number(readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8').trim());
|
|
116
|
+
if (!Number.isFinite(milli) || milli <= 0)
|
|
117
|
+
return null;
|
|
118
|
+
// Usually millidegrees (47000 = 47°C); some SoCs report whole °C.
|
|
119
|
+
return Math.round((milli > 1000 ? milli / 1000 : milli) * 10) / 10;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
63
125
|
collect() {
|
|
64
126
|
const total = os.totalmem();
|
|
65
127
|
const free = os.freemem();
|
|
@@ -80,7 +142,7 @@ export class TelemetryCollector {
|
|
|
80
142
|
percent: total > 0 ? Math.round((used / total) * 100) : 0,
|
|
81
143
|
},
|
|
82
144
|
gpu: null, // populated by an optional platform GPU probe later
|
|
83
|
-
disk:
|
|
145
|
+
disk: this.diskUsage(),
|
|
84
146
|
network: this.network(),
|
|
85
147
|
system: {
|
|
86
148
|
hostname: os.hostname(),
|
|
@@ -90,6 +152,7 @@ export class TelemetryCollector {
|
|
|
90
152
|
kernelRelease: os.release(),
|
|
91
153
|
},
|
|
92
154
|
processes: [],
|
|
155
|
+
temperatureC: this.temperatureC(),
|
|
93
156
|
timestamp: Date.now(),
|
|
94
157
|
};
|
|
95
158
|
}
|
|
@@ -107,6 +107,9 @@ export interface HardwareSnapshot {
|
|
|
107
107
|
cpuPercent: number;
|
|
108
108
|
memoryBytes: number;
|
|
109
109
|
}>;
|
|
110
|
+
/** CPU package temperature in °C (Pi thermal zone / lm-sensors). null when the
|
|
111
|
+
* platform doesn't expose it (e.g. most Windows hosts). */
|
|
112
|
+
temperatureC?: number | null;
|
|
110
113
|
timestamp: number;
|
|
111
114
|
}
|
|
112
115
|
/** Node status entry consumed by the mesh map (neutral form of nodes.status). */
|
package/package.json
CHANGED