infinicode 2.8.27 → 2.8.29

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.
@@ -22,1407 +22,1516 @@
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 const DASHBOARD_HTML = `<!doctype html>
26
- <html lang="en">
27
- <head>
28
- <meta charset="utf-8">
29
- <meta name="viewport" content="width=device-width, initial-scale=1">
30
- <title>RoboPark Control</title>
31
- <style>
32
- /* ── premium tech glass theme ── dark is the committed default; light is a frosted toggle ── */
33
- :root{
34
- --bg:#08070F;
35
- --grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),
36
- radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),
37
- radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),
38
- linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);
39
- --panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;
40
- --glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);
41
- --border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);
42
- --ink:#F2ECDC; --muted:#9E93B4;
43
- --gold:#E6B23C; --gold-bright:#FFD86B;
44
- --ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);
45
- --bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);
46
- --shadow: 0 10px 34px rgba(0,0,0,0.42);
47
- --mono:ui-monospace,"Cascadia Code",Consolas,monospace;
48
- --sans:system-ui,"Segoe UI",Roboto,sans-serif;
49
- }
50
- @media (prefers-color-scheme:dark){:root{}}
51
- :root[data-theme="dark"]{}
52
- :root[data-theme="light"]{
53
- --bg:#EDEAF6;
54
- --grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),
55
- radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),
56
- linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);
57
- --panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;
58
- --glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);
59
- --border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);
60
- --ink:#241A08; --muted:#6E5A3E;
61
- --gold:#8A5A0C; --gold-bright:#B9781A;
62
- --ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);
63
- --bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);
64
- --shadow: 0 10px 30px rgba(60,40,10,0.14);
65
- }
66
- *{box-sizing:border-box;margin:0;padding:0}
67
- html,body{height:100%}
68
- body{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;
69
- color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}
70
- .mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}
71
- .glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);
72
- border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
73
-
74
- /* top bar */
75
- header.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;
76
- padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);
77
- background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
78
- position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}
79
- .brand{display:flex;align-items:center;gap:0.6rem;}
80
- .livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 2.4s infinite;flex:none;}
81
- .livedot.stale{background:var(--muted);box-shadow:none;animation:none;}
82
- @keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent),0 0 12px var(--ok)}70%{box-shadow:0 0 0 7px transparent,0 0 12px var(--ok)}100%{box-shadow:0 0 0 0 transparent,0 0 12px var(--ok)}}
83
- @media (prefers-reduced-motion:reduce){.livedot{animation:none}}
84
- .brand b{font-size:0.98rem;letter-spacing:-0.01em;}
85
- .brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}
86
- .chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}
87
- .chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);
88
- background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}
89
- .chip b{color:var(--ink);font-weight:600;}
90
- .chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}
91
- .themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);
92
- border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}
93
- .themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
94
-
95
- /* layout */
96
- .wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}
97
- @media (max-width:920px){.wrap{grid-template-columns:1fr}}
98
- section h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);
99
- margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}
100
- section h2 .count{color:var(--muted);}
101
-
102
- /* fleet cards */
103
- .fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}
104
- .node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;
105
- background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
106
- border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
107
- .node:hover{transform:translateY(-2px);border-color:var(--border-strong);}
108
- .node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}
109
- .node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}
110
- .node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
111
- .node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}
112
- .node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}
113
- .node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}
114
- .node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}
115
- .node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);
116
- border-radius:4px;padding:0 0.3em;}
117
- .bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}
118
- :root[data-theme="light"] .bar-track{background:rgba(90,70,20,0.14);}
119
- .bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;box-shadow:0 0 8px color-mix(in srgb,var(--ok) 60%,transparent);}
120
- .bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}
121
- .bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}
122
- .node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}
123
- .caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}
124
- .cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border:1px solid var(--glass-border);border-radius:5px;padding:0.05em 0.4em;}
125
- .empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}
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
-
146
- /* hardware meters */
147
- .hw{border-radius:13px;padding:0.85rem;margin-top:1rem;
148
- background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
149
- border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
150
- .meter{margin-bottom:0.65rem;}
151
- .meter:last-child{margin-bottom:0;}
152
- .meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}
153
- .meter .lab b{color:var(--ink);}
154
-
155
- /* activity feed */
156
- aside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}
157
- .feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;
158
- background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
159
- border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
160
- .line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}
161
- .line:last-child{border-bottom:none;}
162
- .line .t{color:var(--muted);flex:none;}
163
- .line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
164
- .line .d{color:var(--ink);flex:1;word-break:break-word;}
165
- .line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}
166
-
167
- /* command bar */
168
- footer.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;
169
- background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
170
- 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);}
171
- footer .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}
172
- footer .sel b{color:var(--ink);}
173
- footer input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);
174
- border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}
175
- :root[data-theme="light"] footer input{background:rgba(255,255,255,0.6);}
176
- footer input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
177
- button.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;
178
- 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);}
179
- button.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}
180
- button.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}
181
- button.act:disabled{opacity:0.5;cursor:default;}
182
- #out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;
183
- background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);
184
- border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;
185
- white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}
186
- #out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}
187
- .offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;
188
- border-bottom:1px solid var(--glass-border);}
189
-
190
- /* tabs + product panes */
191
- .tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);
192
- background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
193
- position:sticky;top:53px;z-index:19;}
194
- .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;}
195
- .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);}
196
- .tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}
197
- .pane{display:none;} .pane.active{display:block;}
198
- .rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}
199
- .rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}
200
- .rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}
201
- .rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);
202
- background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
203
- border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
204
- .rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}
205
- .rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}
206
- .rp-m{display:flex;flex-direction:column;}
207
- .rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}
208
- .rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
209
- .rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}
210
- .rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}
211
- .pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}
212
- .pm .state{font-weight:700;letter-spacing:0.03em;}
213
- .pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}
214
- .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;}
215
- .toggle::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}
216
- .toggle[aria-checked="true"]{background:var(--ok-bg);border-color:var(--ok);}
217
- .toggle[aria-checked="true"]::after{transform:translateX(20px);background:var(--ok);}
218
- .toggle:disabled{opacity:0.45;cursor:default;}
219
- .toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
220
- .rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}
221
- .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;}
222
- .rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}
223
- .rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}
224
- .rp-btn:disabled{opacity:0.4;cursor:default;}
225
- .rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
226
- .rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}
227
-
228
- /* ── Park (isometric live map) ── */
229
- #pane-park{padding:0;}
230
- .pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}
231
- #pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}
232
- #pk-canvas:active{cursor:grabbing;}
233
- .pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;
234
- display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);
235
- border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
236
- .pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);
237
- padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}
238
- .pk-feed .rows{overflow-y:auto;padding:0.25rem;}
239
- .pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}
240
- .pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}
241
- .pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}
242
- .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;}
243
- .pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}
244
- .pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}
245
- .pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}
246
- .pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}
247
- .pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);
248
- 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);}
249
- .pk-hint b{color:var(--gold-bright);}
250
- .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);}
251
- .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;}
252
- .pk-legend i{width:8px;height:8px;border-radius:2px;}
253
-
254
- /* robot drawer */
255
- .pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;
256
- background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);
257
- border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);
258
- transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}
259
- .pk-drawer.open{transform:translateX(0);}
260
- .pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}
261
- .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));
262
- border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}
263
- .pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}
264
- .pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}
265
- .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;}
266
- .pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}
267
- .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;}
268
- .pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}
269
- .pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}
270
- .pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}
271
- .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);}
272
- .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;}
273
- .pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}
274
- .pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}
275
- .pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}
276
- .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;}
277
- .pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}
278
- .pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}
279
- .pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}
280
- .pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}
281
- .pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}
282
- .pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}
283
- .pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}
284
- .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;}
285
- .pk-wave{height:32px;width:100%;}
286
- .pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}
287
- .pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
288
- .pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}
289
- .pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}
290
- .pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}
291
- .pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}
292
- .pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}
293
- .pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}
294
- .pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
295
- .pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
296
- .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
297
- </style>
298
- </head>
299
- <body>
300
- <div class="offline" id="offline">connection lost — retrying</div>
301
- <header class="bar">
302
- <div class="brand">
303
- <span class="livedot" id="livedot"></span>
304
- <div>
305
- <b>RoboPark Control</b>
306
- <div class="who mono" id="who">connecting…</div>
307
- </div>
308
- </div>
309
- <div class="chips" id="summary"></div>
310
- <button class="themebtn" id="theme">Theme</button>
311
- </header>
312
-
313
- <div class="tabs">
314
- <button class="tab active" data-tab="fleet" id="tabbtn-fleet">Fleet</button>
315
- <button class="tab" data-tab="park" id="tabbtn-park">Park</button>
316
- <button class="tab" data-tab="robopark" id="tabbtn-robopark">Sessions &amp; Telemetry</button>
317
- </div>
318
-
319
- <div class="pane active" id="pane-fleet">
320
- <div class="wrap">
321
- <section>
322
- <h2>Fleet <span class="count" id="fleetcount"></span></h2>
323
- <div class="fleet" id="fleet"></div>
324
- <div class="hw" id="hw"></div>
325
- </section>
326
- <aside>
327
- <h2>Live activity</h2>
328
- <div class="feed" id="feed"></div>
329
- </aside>
330
- </div>
331
- </div>
332
-
333
- <div class="pane" id="pane-park">
334
- <div class="pk-stage" id="pk-stage">
335
- <canvas id="pk-canvas"></canvas>
336
- <div class="pk-legend">
337
- <span><i style="background:var(--gold-bright)"></i>hub</span>
338
- <span><i style="background:var(--ok);border-radius:50%"></i>robot</span>
339
- <span><i style="background:var(--info)"></i>peer</span>
340
- <span><i style="background:var(--info)"></i>dispatch</span>
341
- <span><i style="background:var(--ok)"></i>telemetry</span>
342
- </div>
343
- <aside class="pk-feed">
344
- <h3><span>Live activity</span><span style="color:var(--muted)" id="pk-src">/fed/stream</span></h3>
345
- <div class="rows" id="pk-rows"></div>
346
- </aside>
347
- <div class="pk-hint">drag to pan · scroll to zoom · <b>click a robot</b> for cam / mic / speaker</div>
348
- </div>
349
- <div class="pk-drawer" id="pk-drawer">
350
- <div class="pk-dh">
351
- <div class="pk-av" id="pk-av">🤖</div>
352
- <div><div class="nm" id="pk-name">robot</div><div class="sub" id="pk-sub">satellite</div></div>
353
- <span class="pk-pill idle" id="pk-state" style="margin-left:0.4rem">idle</span>
354
- <button class="x" id="pk-close">✕</button>
355
- </div>
356
- <div class="pk-dbody" id="pk-body"></div>
357
- </div>
358
- </div>
359
-
360
- <div class="pane" id="pane-robopark">
361
- <div class="rp-head">
362
- <h2 style="margin:0">RoboPark · sessions &amp; telemetry</h2>
363
- <div class="rp-controls">
364
- <span class="pm" id="pm-wrap" hidden>
365
- <span>production mode</span>
366
- <button class="toggle" id="pm-toggle" role="switch" aria-checked="false" title="master enable — robots only join sessions when ON"></button>
367
- <span class="state off" id="pm-state">off</span>
368
- </span>
369
- <div class="rp-totals" id="rp-totals"></div>
370
- </div>
371
- </div>
372
- <div class="rp-grid" id="rp-grid"></div>
373
- </div>
374
-
375
- <footer class="cmd">
376
- <span class="sel">on <b id="selName">this node</b></span>
377
- <input id="cmdInput" placeholder="/status /nodes /health (Enter to run)" autocomplete="off" spellcheck="false">
378
- <button class="act" id="runBtn">Run</button>
379
- <button class="act ghost" id="applyBtn">Apply config to fleet</button>
380
- <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>
381
- </footer>
382
- <div id="out"><button class="x" id="outClose">close</button><span id="outText"></span></div>
383
-
384
- <script>
385
- (function(){
386
- var TOKEN = new URLSearchParams(location.search).get('token') || '';
387
- var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
388
- var selfId = null, selected = null, lastSeq = -1, everOk = false;
389
- var rpReadOnly = false, pmBusy = false;
390
- var lastStatus = null; // most recent /fed/status snapshot (shared with Park)
391
- var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)
392
-
393
- var $ = function(id){ return document.getElementById(id); };
394
- function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }
395
- 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]; }
396
- function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }
397
- 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()); }
398
-
399
- // theme (dark glass is the default; toggle to frosted light)
400
- (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}
401
- document.documentElement.setAttribute('data-theme', s || 'dark');
402
- $('theme').addEventListener('click', function(){
403
- var cur=document.documentElement.getAttribute('data-theme')||'dark';
404
- var nx=cur==='dark'?'light':'dark';
405
- document.documentElement.setAttribute('data-theme', nx);
406
- try{localStorage.setItem(k,nx)}catch(e){}
407
- });
408
- })();
409
-
410
- function detailOf(data){
411
- var p = data || {};
412
- if(typeof p==='string') return p;
413
- if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' ✓':' ✗'):'');
414
- if(p.model) return String(p.model);
415
- if(p.provider) return String(p.provider);
416
- if(p.reason) return String(p.reason).slice(0,60);
417
- if(p.description) return String(p.description).slice(0,50);
418
- if(p.name) return String(p.name);
419
- return '';
420
- }
421
- function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }
422
-
423
- function summarizeFrame(f){
424
- var d = f.d || {};
425
- if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% · mem '+m+'%'; }
426
- if(f.t==='hb'){ return 'heartbeat · load '+(d.load!=null?d.load:'-'); }
427
- if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }
428
- if(f.t==='rt'){ var rp=d.d||{}; return 'route '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }
429
- if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }
430
- if(f.t==='log'){
431
- if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);
432
- return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' ×'+d.count):''); }
433
- return 'log';
434
- }
435
- return f.t;
436
- }
437
-
438
- function renderFeed(activity){
439
- var feed = $('feed');
440
- if(!activity || !activity.length){
441
- if(!feed.dataset.seeded){ feed.innerHTML = '<div class="line"><span class="d" style="color:var(--muted)">waiting for fleet activity — task runs, routing, and peer events stream here.</span></div>'; }
442
- return;
443
- }
444
- feed.dataset.seeded = '1';
445
- var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;
446
- var html = '';
447
- for(var i=0;i<activity.length;i++){
448
- var e = activity[i]; var f = e.f || e;
449
- var name = (f.n||'').slice(0,10);
450
- html += '<div class="line k-'+esc(f.t)+'"><span class="t">'+clock(f.ts)+'</span>'
451
- + '<span class="n" title="'+esc(f.n)+'">'+esc(name)+'</span>'
452
- + '<span class="d">'+esc(summarizeFrame(f))+'</span></div>';
453
- }
454
- feed.innerHTML = html;
455
- if(near) feed.scrollTop = feed.scrollHeight;
456
- }
457
-
458
- function meter(label, value, sub){
459
- var v = Math.max(0, Math.min(100, Math.round(value||0)));
460
- return '<div class="meter"><div class="lab"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'
461
- + (sub?(' '+esc(sub)):'')+'</span></div>'
462
- + '<div class="bar-track"><div class="bar-fill '+loadClass(v)+'" style="width:'+v+'%"></div></div></div>';
463
- }
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
-
504
- function renderHw(hw){
505
- var el = $('hw');
506
- if(!hw){ el.innerHTML = '<div class="meter"><div class="lab"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }
507
- var html = '<h2 style="margin-top:0">This node · '+esc(hw.system?hw.system.hostname:'')+'</h2>';
508
- html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');
509
- html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');
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>';
516
- el.innerHTML = html;
517
- }
518
-
519
- function selectNode(id, name){
520
- selected = (id===selfId)?null:id;
521
- $('selName').textContent = selected ? name : 'this node';
522
- var cards = document.querySelectorAll('.node');
523
- for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);
524
- }
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
- }
532
- function renderFleet(nodes){
533
- var el = $('fleet');
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
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){}
539
- var html = '';
540
- for(var i=0;i<nodes.length;i++){
541
- var n = nodes[i];
542
- var isSelf = n.nodeId===selfId;
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);
554
- var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class="cap">'+esc(caps[c])+'</span>';
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>';
577
- }
578
- el.innerHTML = html;
579
- var cards = el.querySelectorAll('.node');
580
- for(var j=0;j<cards.length;j++){
581
- (function(card){
582
- function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }
583
- card.addEventListener('click', pick);
584
- card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });
585
- })(cards[j]);
586
- }
587
- }
588
-
589
- function renderSummary(s){
590
- var nodes = s.nodes||[];
591
- var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;
592
- var down = nodes.length-up;
593
- var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;
594
- var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});
595
- var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;
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>'
610
- + '<span class="chip'+(down?' bad':'')+'">down <b>'+down+'</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;
617
- $('fleetcount').textContent = '(' + nodes.length + ')';
618
- var self = s.self||{};
619
- var role = s.role||{};
620
- $('who').textContent = (self.displayName||'node') + ' · ' + (self.role||'?') + (role.site?(' · '+role.site):'') + ' · v' + (self.version||'?');
621
- }
622
-
623
- function setLive(ok){
624
- $('livedot').classList.toggle('stale', !ok);
625
- $('offline').style.display = (!ok && everOk) ? 'block' : 'none';
626
- }
627
-
628
- function poll(){
629
- fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){
630
- if(!r.ok) throw new Error('status '+r.status);
631
- return r.json();
632
- }).then(function(s){
633
- everOk = true; setLive(true); lastStatus = s;
634
- if(!selfId && s.self) selfId = s.self.nodeId;
635
- if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
636
- updateHw(s);
637
- renderSummary(s);
638
- renderFleet(s.nodes||[]);
639
- renderHw(s.hardware);
640
- renderFeed(s.activity||[]);
641
- Park.onStatus(s);
642
- }).catch(function(){ setLive(false); });
643
- }
644
-
645
- function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }
646
- $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });
647
-
648
- function runCommand(input){
649
- input = (input||'').trim();
650
- if(!input) return;
651
- var payload = selected ? ('/node ' + selected + ' ' + input) : input;
652
- $('runBtn').disabled = true;
653
- fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })
654
- .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })
655
- .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })
656
- .catch(function(e){ showOut('request failed: '+e.message); })
657
- .then(function(){ $('runBtn').disabled=false; });
658
- }
659
-
660
- $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });
661
- $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });
662
- $('applyBtn').addEventListener('click', function(){
663
- $('applyBtn').disabled = true;
664
- fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })
665
- .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })
666
- .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })
667
- .catch(function(e){ showOut('apply failed: '+e.message); })
668
- .then(function(){ $('applyBtn').disabled=false; });
669
- });
670
- $('updateBtn').addEventListener('click', function(){
671
- 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;
672
- $('updateBtn').disabled = true;
673
- showOut('Updating fleet — installing latest on each node…');
674
- fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })
675
- .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })
676
- .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })
677
- .catch(function(e){ showOut('update failed: '+e.message); })
678
- .then(function(){ $('updateBtn').disabled=false; });
679
- });
680
-
681
- // ── tabs: Fleet + Park + RoboPark sessions/telemetry ──
682
- var tabs = document.querySelectorAll('.tab');
683
- var activeTab = 'fleet';
684
- for (var ti=0; ti<tabs.length; ti++){
685
- (function(btn){
686
- btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });
687
- })(tabs[ti]);
688
- }
689
- function selectTab(name){
690
- activeTab = name;
691
- for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);
692
- $('pane-fleet').classList.toggle('active', name==='fleet');
693
- $('pane-park').classList.toggle('active', name==='park');
694
- $('pane-robopark').classList.toggle('active', name==='robopark');
695
- if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
696
- if (name==='robopark') pollRobopark();
697
- if (name==='park') Park.activate(); else Park.deactivate();
698
- }
699
- var initHash = location.hash.replace('#','');
700
- // Defer tab init until Park module is defined (same outer IIFE, later in source).
701
- function initTab(){
702
- if (initHash==='robopark'||initHash==='park') selectTab(initHash);
703
- }
704
- // initTab() is called at the very bottom of this IIFE, after Park is assigned.
705
-
706
- function robotsFrom(d){
707
- if (Array.isArray(d)) return d;
708
- if (d && Array.isArray(d.robots)) return d.robots;
709
- return [];
710
- }
711
- function applyReadOnly(){
712
- var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;
713
- var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;
714
- var note=$('rp-ro-note');
715
- if(rpReadOnly && !note){
716
- note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';
717
- note.textContent='read-only (no token) — start the hub with --token to enable control';
718
- var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);
719
- } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }
720
- }
721
-
722
- function rpAction(method, path, body){
723
- if(rpReadOnly){ showOut('This node is read-only — restart the hub with --token to enable control actions.'); return Promise.resolve(null); }
724
- return fetch(path+QS, {
725
- method: method, cache:'no-store',
726
- headers: body?{'content-type':'application/json'}:undefined,
727
- body: body?JSON.stringify(body):undefined
728
- }).then(function(r){
729
- if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }
730
- 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; });
731
- });
732
- }
733
-
734
- function setPm(on){
735
- var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');
736
- if(!wrap) return;
737
- wrap.hidden=false;
738
- tg.setAttribute('aria-checked', on?'true':'false');
739
- st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');
740
- }
741
- (function(){
742
- var tg=$('pm-toggle'); if(!tg) return;
743
- tg.addEventListener('click', function(){
744
- if(rpReadOnly||pmBusy) return;
745
- var next = tg.getAttribute('aria-checked')!=='true';
746
- pmBusy=true; tg.disabled=true;
747
- rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){
748
- setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);
749
- }).catch(function(e){ showOut('production_mode: '+e.message); })
750
- .then(function(){ pmBusy=false; applyReadOnly(); });
751
- });
752
- })();
753
- function pollSettings(){
754
- fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){
755
- if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);
756
- }).catch(function(){});
757
- }
758
-
759
- function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }
760
- function endSession(id){
761
- if(!confirm('End the active session for '+id+'?')) return;
762
- rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){
763
- showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();
764
- }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });
765
- }
766
- $('rp-grid').addEventListener('click', function(e){
767
- var b=e.target.closest?e.target.closest('.rp-btn'):null; if(!b) return;
768
- var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');
769
- if(act==='end') endSession(id);
770
- });
771
-
772
- function pollRobopark(){
773
- pollSettings();
774
- fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
775
- var grid = $('rp-grid'), totals = $('rp-totals');
776
- // stash per-robot rows so the Park drawer can show live session stats
777
- roboparkByName = {};
778
- robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });
779
- if (d && (d.disabled || d.ok===false)){
780
- totals.innerHTML=''; $('pm-wrap').hidden=true;
781
- grid.innerHTML = '<div class="empty" style="grid-column:1/-1">'+esc(d.error||'no scheduler linked')+' — start the hub with <span class="mono">--scheduler-url http://&lt;scheduler&gt;:8080</span></div>';
782
- return;
783
- }
784
- var robots = robotsFrom(d);
785
- var tot = (d && d.totals) || {};
786
- var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);
787
- var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);
788
- totals.innerHTML =
789
- '<span class="chip">robots <b>'+robots.length+'</b></span>'
790
- + '<span class="chip">sessions <b>'+totalSessions+'</b></span>'
791
- + '<span class="chip">triggers <b>'+totalTrig+'</b></span>';
792
- if (!robots.length){ grid.innerHTML='<div class="empty" style="grid-column:1/-1">No robot telemetry yet — it populates as robots trigger sessions.</div>'; return; }
793
- var html='';
794
- for (var i=0;i<robots.length;i++){
795
- var x = robots[i];
796
- var name = robotName(x);
797
- var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'';
798
- var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';
799
- var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'—';
800
- var active = x.active_session||x.in_session||x.session_active;
801
- var idAttr = esc(name);
802
- html += '<div class="rp-card"><h4>'+esc(name)+(active?' <span class="chip" style="color:var(--ok)">live</span>':'')+'</h4><div class="rp-metrics">'
803
- + '<div class="rp-m"><span class="v">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class="l">triggers</span></div>'
804
- + '<div class="rp-m"><span class="v">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class="l">sessions</span></div>'
805
- + '<div class="rp-m"><span class="v">'+lat+'</span><span class="l">avg latency</span></div>'
806
- + '<div class="rp-m"><span class="v '+dcls+'">'+drop+'</span><span class="l">drop rate</span></div>'
807
- + '</div><div class="rp-actions">'
808
- + '<button class="rp-btn danger" data-act="end" data-robot="'+idAttr+'"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'
809
- + '</div></div>';
810
- }
811
- grid.innerHTML = html;
812
- }).catch(function(){
813
- $('rp-grid').innerHTML='<div class="empty" style="grid-column:1/-1">telemetry unreachable</div>';
814
- });
815
- }
816
-
817
- // ══════════════════════════════════════════════════════════════════
818
- // Park isometric live map of the mesh
819
- // Real data: /fed/status nodes (placement + load) + /fed/stream frames
820
- // (agent movement + tool-use pulses) + /robopark telemetry (session stats).
821
- // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.
822
- // ══════════════════════════════════════════════════════════════════
823
- var Park = (function(){
824
- var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;
825
- var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
826
- var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;
827
- var previewOn=false, previewTimer=null; // on-demand operator camera preview
828
- var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
829
- var lkRoom=null, micAnalyser=null, audioCtx=null;
830
- var imgs={}; // preloaded robot concept avatars
831
- var TW=44, TH=22, PARK_W=15, PARK_D=8;
832
- // The real park (from the aerial photo): named robot pads at their ground
833
- // positions along the long Shenzhen Bay promenade plaza, plus palms, pond,
834
- // street and stage. A mesh node binds to a pad when its display name matches
835
- // the pad keywords (robobmw → VIXEN/BMW).
836
- var SLOTS=[
837
- {label:'VOLT', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},
838
- {label:'TESLA', keys:['tesla'], nx:0.38, ny:0.30, img:'/static/robopark/tesla.png'},
839
- {label:'JAGUAR', keys:['jaguar','jag'], nx:0.58, ny:0.34, img:'/static/robopark/jaguar.png'},
840
- {label:'VIXEN', keys:['vixen','bmw'], nx:0.54, ny:0.66, sub:'BMW', img:'/static/robopark/bmw.png'},
841
- {label:'PANDA', keys:['panda'], nx:0.82, ny:0.52, img:'/static/robopark/panda.png'}
842
- ];
843
- var HUBSLOT={label:'CONTROL', keys:[], nx:0.06, ny:0.16};
844
- // Palm positions tuned to the aerial: clusters along the back edge and
845
- // scattered down the center of the paved strip.
846
- var PALMS=[
847
- [0.10,0.38],[0.14,0.55],[0.18,0.72],[0.22,0.30],[0.26,0.48],[0.30,0.66],
848
- [0.36,0.24],[0.40,0.42],[0.44,0.60],[0.50,0.26],[0.54,0.46],[0.58,0.64],
849
- [0.66,0.30],[0.70,0.50],[0.74,0.68],[0.80,0.36],[0.84,0.56],[0.90,0.40],[0.94,0.62],
850
- [0.62,0.80],[0.76,0.84],[0.35,0.82]
851
- ];
852
- // Pond / curved water feature at the lower-left of the plaza.
853
- var POND={cx:0.28*15, cy:0.96*8, rx:3.4, ry:1.6};
854
- // Street traffic along the top road (yellow taxis + private cars).
855
- var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];
856
-
857
- function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }
858
- function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }
859
- function kindOf(n){
860
- var r=(n.role||'').toLowerCase();
861
- if(r==='hub'||r==='scheduler') return 'hub';
862
- if(r==='satellite'||r==='robot') return 'robot';
863
- var caps=(n.capabilities||[]).join(' ');
864
- if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';
865
- return 'peer';
866
- }
867
- function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
868
- function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
869
-
870
- // Bind mesh nodes onto the fixed park pads; unmatched pads render as
871
- // offline placeholders so the whole park is always visible.
872
- function layout(src){
873
- var list=(src||[]).slice(), used={}, out=[];
874
- function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }
875
- function bind(slot, wx, wy, kind){
876
- var match=null;
877
- for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;
878
- var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';
879
- if(kind==='hub'){ if(isHub){ match=n; break; } }
880
- 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; }
881
- }
882
- var id = match?match.nodeId:('pad:'+slot.label);
883
- if(match) used[match.nodeId]=1;
884
- return {
885
- id:id, kind:kind, label:slot.label,
886
- name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),
887
- sub: match?(slot.sub||''):'not joined',
888
- connected: match?!!match.connected:false,
889
- load: (match&&match.load!=null)?match.load:0,
890
- platform: match?(match.platform||''):'', version: match?(match.version||'?'):'—',
891
- site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
892
- telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
893
- wx:wx, wy:wy, _hit:prevHit(id)
894
- };
895
- }
896
- var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));
897
- SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });
898
- // Any extra live node that matched no pad → line it along the near edge.
899
- var ex=0;
900
- list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;
901
- var w=W2(0.20+ex*0.13, 0.90); ex++;
902
- out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),
903
- sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',
904
- site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });
905
- });
906
- return out;
907
- }
908
-
909
- function onStatus(s){
910
- if(!s) return;
911
- nodes = layout(s.nodes||[]);
912
- byId={}; nodes.forEach(function(n){ byId[n.id]=n; });
913
- // if not streaming, animate from the polled activity tail
914
- if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }
915
- }
916
-
917
- function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }
918
- function findByFrame(f){
919
- var key=(f.n==null?'':String(f.n)).toLowerCase(); if(!key) return null;
920
- for(var i=0;i<nodes.length;i++){ var n=nodes[i];
921
- if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;
922
- for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }
923
- }
924
- return null;
925
- }
926
- function ingest(f, fromPoll){
927
- if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }
928
- var n=findByFrame(f), h=hub();
929
- if(n && h){
930
- if(n.kind==='hub'){ pulse(n, glyphFor(f)); }
931
- else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }
932
- else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }
933
- }
934
- feedRow(f);
935
- }
936
- function glyphFor(f){
937
- if(f.t==='rt') return '⇄';
938
- if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '⌨'; if(/session|presence/.test(k)) return '🎙'; return '✦'; }
939
- if(f.t==='hw'||f.t==='hb') return '';
940
- return '';
941
- }
942
- function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }
943
- function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'✦'}); }
944
-
945
- function feedRow(f){
946
- var rows=$('pk-rows'); if(!rows) return;
947
- var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;
948
- var r=document.createElement('div'); r.className='pk-row';
949
- r.innerHTML='<span class="k pk-k-'+esc(kind)+'"></span><span class="t">'+clock(f.ts)+'</span>'
950
- +'<span class="who" title="'+esc(f.n)+'">'+esc((f.n||'').slice(0,10))+'</span>'
951
- +'<span class="msg">'+esc(summarizeFrame(f))+'</span>';
952
- rows.insertBefore(r, rows.firstChild);
953
- while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);
954
- }
955
-
956
- // ── canvas + projection ──
957
- function ensure(){
958
- if(C) return;
959
- C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();
960
- DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));
961
- var d=false,px,py;
962
- C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});
963
- window.addEventListener('mouseup',function(){d=false;});
964
- window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });
965
- 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});
966
- C.addEventListener('click',function(e){
967
- var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;
968
- 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]; }
969
- if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }
970
- });
971
- window.addEventListener('resize', function(){ if(running) resize(); });
972
- }
973
- function resize(){
974
- var st=$('pk-stage'); if(!st) return;
975
- W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);
976
- if(!fitted){ fit(); fitted=true; }
977
- }
978
- function fit(){
979
- var span=PARK_W+PARK_D;
980
- var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);
981
- cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;
982
- }
983
- // World (park) coords → screen. Origin biased left so the right-docked feed
984
- // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).
985
- function iso(wx,wy){
986
- var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);
987
- return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };
988
- }
989
- function poly(pts, fill, stroke, lw){
990
- X.beginPath();
991
- 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); }
992
- X.closePath();
993
- if(fill){ X.fillStyle=fill; X.fill(); }
994
- if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }
995
- }
996
- function drawGround(){
997
- // grass base
998
- 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)');
999
- 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);
1000
- // street band along the far edge + dashed centerline + cars
1001
- 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);
1002
- X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);
1003
- 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([]);
1004
- CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });
1005
- // white event tent / stage on the far left of the plaza (matches aerial)
1006
- drawTent(0.85, 0.18);
1007
- // paved plaza — long rectangular promenade matching the aerial footprint
1008
- var px0=0.2,py0=-0.10,px1=PARK_W-0.2,py1=PARK_D-0.45;
1009
- 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)');
1010
- poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);
1011
- // plaza grid (tile joints)
1012
- X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;
1013
- for(var gx=Math.ceil(px0);gx<px1;gx+=1.5){ 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(); }
1014
- for(var gy=Math.ceil(py0);gy<py1;gy+=1.5){ 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(); }
1015
- // curved pond / water feature at bottom-left
1016
- var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/12){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)*0.7]); }
1017
- 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)');
1018
- poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);
1019
- // winding footpath along the pond
1020
- X.strokeStyle='rgba(210,190,150,0.35)'; X.lineWidth=2.5*cam.z; X.setLineDash([]);
1021
- var pathPts=[[0.15*PARK_W,0.82*PARK_D],[0.22*PARK_W,0.88*PARK_D],[0.32*PARK_W,0.92*PARK_D],[0.45*PARK_W,0.95*PARK_D]];
1022
- X.beginPath();
1023
- for(var pi=0;pi<pathPts.length;pi++){ var pp=iso(pathPts[pi][0],pathPts[pi][1]); if(pi===0)X.moveTo(pp.x,pp.y); else X.lineTo(pp.x,pp.y); }
1024
- X.stroke();
1025
- }
1026
- function drawTent(wx,wy){
1027
- var p=iso(wx,wy), z=cam.z;
1028
- // canopy shadow
1029
- X.fillStyle='rgba(0,0,0,0.28)'; X.beginPath(); X.ellipse(p.x,p.y+4*z,22*z,8*z,0,0,7); X.fill();
1030
- // white peaked tent
1031
- X.fillStyle='rgba(235,235,240,0.92)'; X.strokeStyle='rgba(180,180,190,0.6)'; X.lineWidth=1;
1032
- X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x,p.y-26*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();
1033
- X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x-12*z,p.y+16*z); X.lineTo(p.x+12*z,p.y+16*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();
1034
- // support poles
1035
- X.strokeStyle='rgba(160,160,170,0.7)'; X.lineWidth=1.5*z;
1036
- X.beginPath(); X.moveTo(p.x-10*z,p.y+16*z); X.lineTo(p.x-10*z,p.y+5*z); X.stroke();
1037
- X.beginPath(); X.moveTo(p.x+10*z,p.y+16*z); X.lineTo(p.x+10*z,p.y+5*z); X.stroke();
1038
- }
1039
- function drawCar(wx,wy,col){
1040
- var p=iso(wx,wy), z=cam.z;
1041
- 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();
1042
- X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();
1043
- 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();
1044
- }
1045
- function drawPalm(wx,wy){
1046
- var p=iso(wx,wy), z=cam.z;
1047
- 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();
1048
- 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();
1049
- var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;
1050
- 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(); }
1051
- X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();
1052
- }
1053
- function ring(cx,cy,r,frac,color){
1054
- 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();
1055
- 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();
1056
- }
1057
- function label(cx,cy,txt,col,sub){
1058
- var z=cam.z; X.textAlign='center';
1059
- X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);
1060
- X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);
1061
- }
1062
- 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(); }
1063
- function drawHub(n){
1064
- 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;
1065
- 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();
1066
- X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
1067
- X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();
1068
- 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; }
1069
- X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();
1070
- X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();
1071
- label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
1072
- n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};
1073
- }
1074
- function drawRobot(n){
1075
- 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;
1076
- 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);
1077
- 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();
1078
- var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';
1079
- X.globalAlpha=n.connected?1:0.7;
1080
- var img = n.connected && imgs[n.label];
1081
- if(img && img.complete && img.naturalWidth>0){
1082
- var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;
1083
- X.save();
1084
- X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.clip();
1085
- X.drawImage(img, bx-13*z, by-44*z, iw, ih+18*z);
1086
- X.restore();
1087
- X.strokeStyle=col; X.lineWidth=1.6; X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.stroke();
1088
- } else {
1089
- X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;
1090
- X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();
1091
- 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();
1092
- 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();
1093
- X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();
1094
- X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();
1095
- }
1096
- X.globalAlpha=1;
1097
- var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' · ':'')+Math.round(n.load)+'%'):'not joined';
1098
- label(bx,p.y+17*z,n.label,col,sub);
1099
- n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};
1100
- }
1101
- function drawPeer(n){
1102
- var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;
1103
- 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();
1104
- var col=n.connected?getVar('--info'):'rgba(150,143,120,0.5)';
1105
- X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;
1106
- X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();
1107
- 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;
1108
- label(bx,p.y+15*z,n.label,col,(n.connected?n.sub:'offline'));
1109
- n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};
1110
- }
1111
-
1112
- function frame(){
1113
- if(!running) return;
1114
- X.clearRect(0,0,W,H);
1115
- drawGround();
1116
- // depth-sort palms + nodes so nearer objects overlap farther ones
1117
- var objs=[];
1118
- 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}); }
1119
- for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }
1120
- objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });
1121
- 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); } });
1122
- 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;}
1123
- 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;
1124
- 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;
1125
- 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;
1126
- }
1127
- 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;}
1128
- var pp=iso(pu.n.wx,pu.n.wy), yy=pp.y-56*cam.z-pu.t*24*cam.z;
1129
- 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);
1130
- 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;
1131
- }
1132
- raf=requestAnimationFrame(frame);
1133
- }
1134
-
1135
- function connectStream(){
1136
- if(es) return;
1137
- try{
1138
- es=new EventSource('/fed/stream'+QS);
1139
- es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };
1140
- es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream · live'; };
1141
- es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status · poll'; };
1142
- }catch(e){ es=null; }
1143
- }
1144
-
1145
- var sdkLoading=false;
1146
- function loadSDK(){
1147
- if(window.LivekitClient || sdkLoading) return; sdkLoading=true;
1148
- var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;
1149
- s.onerror=function(){ sdkLoading=false; };
1150
- // The SDK is ~500KB — a camera view or "Go live" click can easily fire
1151
- // before it finishes loading. Without this, that first attempt sees
1152
- // window.LivekitClient===undefined, falls into the permanent "no-sdk"
1153
- // idle state, and never retries even once the SDK is ready.
1154
- s.onload=function(){
1155
- sdkLoading=false;
1156
- if(!drawerId) return;
1157
- var n=byId[drawerId];
1158
- if(n && n.kind==='robot'){ startCam(n); if(previewOn) startPreview(n); }
1159
- };
1160
- document.head.appendChild(s);
1161
- }
1162
- function loadImages(){
1163
- SLOTS.forEach(function(s){ if(!s.img) return; var i=new Image(); i.src=s.img+QS; i.onload=function(){ imgs[s.label]=i; }; i.onerror=function(){ imgs[s.label]=null; }; });
1164
- }
1165
- function activate(){
1166
- ensure();
1167
- loadSDK();
1168
- loadImages();
1169
- if(lastStatus) onStatus(lastStatus);
1170
- resize();
1171
- connectStream();
1172
- if(!running){ running=true; raf=requestAnimationFrame(frame); }
1173
- }
1174
- function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }
1175
-
1176
- // ── robot drawer ──
1177
- function sect(title, inner){ return '<div class="pk-sect"><h4>'+title+'</h4>'+inner+'</div>'; }
1178
- function openDrawer(id){
1179
- var n=byId[id]; if(!n) return; drawerId=id;
1180
- $('pk-drawer').classList.add('open');
1181
- $('pk-av').textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
1182
- $('pk-name').textContent=n.label||n.name;
1183
- $('pk-sub').textContent = n.connected ? (n.name+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'')) : 'pad reserved · not joined';
1184
- var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');
1185
- buildDrawer(n);
1186
- pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick
1187
- if(drawerTimer) clearInterval(drawerTimer);
1188
- drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);
1189
- }
1190
- // Resolve a mesh node's *scheduler* robot id — n.name is the mesh display
1191
- // name, which is not necessarily the scheduler's robots.id (e.g. an
1192
- // enrolled device's id is a generated dev_xxxx, distinct from its name).
1193
- // Falls back to n.name if telemetry hasn't matched a row yet.
1194
- function schedRow(n){
1195
- var row=roboparkByName[n.name];
1196
- 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; } }
1197
- return row;
1198
- }
1199
- function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }
1200
- function closeDrawer(){
1201
- // tell the scheduler to end any live preview so the robot stops its cam
1202
- if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }
1203
- previewOn=false; if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
1204
- stopVision();
1205
- $('pk-drawer').classList.remove('open'); drawerId=null;
1206
- if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;
1207
- if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
1208
- if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
1209
- if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
1210
- if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
1211
- micAnalyser=null;
1212
- }
1213
- // ── on-demand operator preview: open the robot's cam without a scene-detection session ──
1214
- function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'■ Stop live':'▶ Go live'; b.classList.toggle('danger', previewOn); } }
1215
- function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }
1216
- function startPreview(n){
1217
- previewOn=true; updateGoLive();
1218
- var note=$('pk-golivenote'); if(note) note.textContent='requesting preview…';
1219
- if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first
1220
- idleCam('connecting');
1221
- fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/start'+QS, {method:'POST', cache:'no-store'})
1222
- .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
1223
- .then(function(info){
1224
- if(!previewOn) return; // stopped while awaiting
1225
- if(info && info.active===false){ idleCam(info.reason||'idle'); if(note) note.textContent=(info.reason==='production_mode_off'?'production mode is off':'preview unavailable'); previewOn=false; updateGoLive(); return; }
1226
- if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview · robot opening camera…'; }
1227
- else { idleCam('no-sdk'); }
1228
- if(previewTimer) clearInterval(previewTimer);
1229
- previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);
1230
- })
1231
- .catch(function(){ idleCam('unreachable'); if(note) note.textContent='scheduler unreachable'; previewOn=false; updateGoLive(); });
1232
- }
1233
- function stopPreview(n){
1234
- previewOn=false; updateGoLive();
1235
- if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
1236
- if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
1237
- fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/stop'+QS, {method:'POST'}).catch(function(){});
1238
- var note=$('pk-golivenote'); if(note) note.textContent='';
1239
- idleCam('idle');
1240
- }
1241
- // RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),
1242
- // so the browser hits the robot's LAN IP straight on, no scheduler proxy.
1243
- // The scheduler learns that IP from the device's own heartbeats.
1244
- function toggleVision(n){
1245
- var img=$('pk-vision-img'), note=$('pk-vision-note'), btn=$('pk-vision-toggle');
1246
- if(visionOn){
1247
- visionOn=false; if(img){ img.src=''; img.style.display='none'; }
1248
- if(btn) btn.textContent='🔍 Show overlay';
1249
- if(note) note.textContent="object/motion detection overlay from the robot's vision server (RoboVisionAI_PI)";
1250
- return;
1251
- }
1252
- var row=schedRow(n);
1253
- var ip=row&&row.lan_ip;
1254
- if(!ip){ if(note) note.textContent='no known IP for this robot yet waiting for a heartbeat with lan_ip'; return; }
1255
- var port=(row&&row.vision_port)||5000;
1256
- visionOn=true;
1257
- if(btn) btn.textContent='■ Hide overlay';
1258
- if(note) note.textContent='streaming from http://'+ip+':'+port+'/video_feed';
1259
- if(img){ img.src='http://'+ip+':'+port+'/video_feed'; img.style.display='block'; }
1260
- }
1261
- function stopVision(){
1262
- visionOn=false;
1263
- var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }
1264
- }
1265
- function pollRoboparkQuiet(){
1266
- fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
1267
- var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};
1268
- list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; if(x.name) roboparkByName[x.name]=x; });
1269
- refreshDrawer();
1270
- }).catch(function(){});
1271
- }
1272
- function buildDrawer(n){
1273
- var b=$('pk-body');
1274
- if(n.kind==='robot' && !n.connected){
1275
- b.innerHTML =
1276
- sect('🅿 park pad · '+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>')
1277
- + sect('📍 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>');
1278
- return;
1279
- }
1280
- if(n.kind==='robot'){
1281
- b.innerHTML =
1282
- sect('👁 camera feed',
1283
- '<div class="pk-cam" id="pk-cam"><canvas id="pk-camcv" width="384" height="216"></canvas>'
1284
- +'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
1285
- +'<div class="pk-note" id="pk-camnote">seeking LiveKit track…</div>'
1286
- +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>')
1287
- + sect('🔍 vision overlay',
1288
- '<div class="pk-cam" id="pk-vision-wrap"><img id="pk-vision-img" style="width:100%;display:none;border-radius:inherit" alt="vision feed"/></div>'
1289
- +'<div class="pk-note" id="pk-vision-note">object/motion detection overlay from the robot vision server (RoboVisionAI_PI)</div>'
1290
- +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-vision-toggle">🔍 Show overlay</button></div>')
1291
- + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
1292
- + sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">awaiting robot audio plugin</div>')
1293
- + sect('📊 hardware','<div class="pk-hwgrid"><div><div class="l">load</div><div class="track"><div class="fill" id="pk-hw-load"></div></div></div><div><div class="l">status</div><div class="track"><div class="fill" id="pk-hw-conn" style="background:'+(n.connected?'var(--ok)':'var(--muted)')+'"></div></div></div></div><div class="pk-note">remote node — full CPU/mem telemetry shows on the node itself</div>')
1294
- + sect('🗓 session telemetry','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-trig">—</div><div class="l">triggers</div></div><div class="pk-kv"><div class="v" id="pk-tv-sess">—</div><div class="l">sessions</div></div><div class="pk-kv"><div class="v" id="pk-tv-lat">—</div><div class="l">avg latency</div></div><div class="pk-kv"><div class="v" id="pk-tv-drop">—</div><div class="l">drop rate</div></div></div>')
1295
- + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn danger" id="pk-end" data-robot="'+esc(n.name)+'">End session</button><button class="rp-btn" id="pk-follow">Follow run</button></div><div class="pk-note">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>');
1296
- startCam(n); startMeters(n);
1297
- var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
1298
- var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });
1299
- var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
1300
- 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); });
1301
- } else {
1302
- b.innerHTML =
1303
- sect('🖥 node','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-load">—</div><div class="l">load</div></div><div class="pk-kv"><div class="v '+(n.connected?'ok':'')+'">'+(n.connected?'up':'down')+'</div><div class="l">status</div></div></div>')
1304
- + sect('🔌 capabilities', (n.caps.length?('<div class="caps">'+n.caps.map(function(c){return '<span class="cap">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class="pk-note">none reported</div>'))
1305
- + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn" id="pk-status">Run /status</button></div>');
1306
- var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });
1307
- }
1308
- refreshDrawer();
1309
- }
1310
- function refreshDrawer(){
1311
- var n=drawerId?byId[drawerId]:null; if(!n) return;
1312
- setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);
1313
- var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
1314
- var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
1315
- var row=schedRow(n);
1316
- if(row){
1317
- set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
1318
- set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
1319
- set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'—');
1320
- var dEl=$('pk-tv-drop'); if(dEl){ var d=row.drop_rate!=null?Math.round(row.drop_rate*100):null; dEl.textContent=d!=null?d+'%':'—'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }
1321
- var active=row.active_session||row.in_session||row.session_active;
1322
- 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')); }
1323
- }
1324
- }
1325
- function set(id,v){ var e=$(id); if(e) e.textContent=v; }
1326
- 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)'; }
1327
-
1328
- // ── cam: target a LiveKit track; fall back to a styled preview ──
1329
- function startCam(n){
1330
- var note=$('pk-camnote');
1331
- camAbort = ('AbortController' in window)?new AbortController():null;
1332
- idleCam('connecting');
1333
- // scheduler mints a subscribe-only viewer token for the robot's current
1334
- // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).
1335
- fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
1336
- .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
1337
- .then(function(info){
1338
- if(info && info.active===false){ idleCam(info.reason||'idle'); return; }
1339
- if(info && info.url && info.token){
1340
- if(window.LivekitClient){ attachLiveKit(info, n); }
1341
- else { idleCam('no-sdk'); }
1342
- } else { idleCam('idle'); }
1343
- })
1344
- .catch(function(){ idleCam('unreachable'); });
1345
- }
1346
- function idleCam(reason){
1347
- var cc=$('pk-camcv'), msg={connecting:'connecting to live session…', idle:'robot idle · no live session', production_mode_off:'production mode off', 'no-sdk':'live session up · loading video…', unreachable:'scheduler unreachable'}[reason]||'no live feed';
1348
- 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); }
1349
- var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'—';
1350
- var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;
1351
- }
1352
- function attachLiveKit(info, n){
1353
- var note=$('pk-camnote'), res=$('pk-camres');
1354
- try{
1355
- var LK=window.LivekitClient; lkRoom=new LK.Room();
1356
- lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){
1357
- if(track.kind==='video'){
1358
- var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
1359
- var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);
1360
- if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit · '+esc(info.room);
1361
- } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }
1362
- });
1363
- lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='—'; });
1364
- lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected · waiting for camera track…'; })
1365
- .catch(function(){ idleCam('unreachable'); });
1366
- }catch(e){ idleCam('no-sdk'); }
1367
- }
1368
- // Real mic level: tap the robot's subscribed 'mic' audio track with a Web
1369
- // Audio analyser and read RMS in startMeters (no invented levels).
1370
- function hookMicAnalyser(track){
1371
- try{
1372
- var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
1373
- audioCtx=audioCtx||new AC();
1374
- var ms=track.mediaStreamTrack; if(!ms) return;
1375
- var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));
1376
- var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;
1377
- }catch(e){}
1378
- }
1379
- function previewCam(n){
1380
- var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();
1381
- var res=$('pk-camres'); if(res && res.textContent==='— · —') res.textContent='preview · 24fps';
1382
- (function loop(){ if(!$('pk-camcv')||drawerId==null) return;
1383
- var tt=(performance.now()-s0)/1000;
1384
- 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);
1385
- 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();
1386
- var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;
1387
- cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();
1388
- cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);
1389
- cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);
1390
- cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);
1391
- 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;
1392
- 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); }
1393
- var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());
1394
- camRAF=requestAnimationFrame(loop);
1395
- })();
1396
- }
1397
- 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'); }
1398
- function startMeters(n){
1399
- var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;
1400
- var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw?sw.getContext('2d'):null, buf=new Uint8Array(128);
1401
- (function loop(){ if(!$('pk-micbars')||drawerId==null) return;
1402
- // MIC — real RMS from the subscribed LiveKit mic track (else no signal).
1403
- var lvl=0;
1404
- 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); }
1405
- 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)'; }
1406
- set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'—');
1407
- set('pk-micstate', micAnalyser?(lvl>0.12?'live · hearing audio':'live · quiet'):'no live mic track');
1408
- // SPEAKER — the Pi plays TTS out its own speaker; it is not published as a
1409
- // track, so report status honestly instead of inventing a level.
1410
- for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }
1411
- set('pk-spkval','—'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');
1412
- 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(); }
1413
- meterRAF=requestAnimationFrame(loop);
1414
- })();
1415
- }
1416
-
1417
- $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);
1418
- return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer };
1419
- })();
1420
-
1421
- poll();
1422
- setInterval(poll, 2000);
1423
- setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);
1424
- initTab();
1425
- })();
1426
- </script>
1427
- </body>
25
+ export const DASHBOARD_HTML = `<!doctype html>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <meta name="viewport" content="width=device-width, initial-scale=1">
30
+ <title>RoboPark Control</title>
31
+ <style>
32
+ /* ── premium tech glass theme ── dark is the committed default; light is a frosted toggle ── */
33
+ :root{
34
+ --bg:#08070F;
35
+ --grad: radial-gradient(1100px 680px at 12% -12%, rgba(255,184,64,0.16), transparent 60%),
36
+ radial-gradient(900px 620px at 102% -6%, rgba(96,132,255,0.15), transparent 58%),
37
+ radial-gradient(700px 700px at 88% 108%, rgba(150,90,255,0.10), transparent 60%),
38
+ linear-gradient(162deg,#0A0814 0%,#0B0A17 42%,#070610 100%);
39
+ --panel: rgba(28,25,42,0.55); --panel-2: rgba(42,37,60,0.60); --panel-solid:#141020;
40
+ --glass-border: rgba(255,255,255,0.10); --glass-hi: rgba(255,255,255,0.06);
41
+ --border: rgba(255,255,255,0.10); --border-strong: rgba(255,214,112,0.42);
42
+ --ink:#F2ECDC; --muted:#9E93B4;
43
+ --gold:#E6B23C; --gold-bright:#FFD86B;
44
+ --ok:#6FE0A6; --ok-bg:rgba(28,64,50,0.55); --warn:#F2C14E; --warn-bg:rgba(64,50,20,0.5);
45
+ --bad:#FF7E6E; --bad-bg:rgba(64,24,20,0.55); --info:#78BBFF; --info-bg:rgba(20,44,66,0.55);
46
+ --shadow: 0 10px 34px rgba(0,0,0,0.42);
47
+ --mono:ui-monospace,"Cascadia Code",Consolas,monospace;
48
+ --sans:system-ui,"Segoe UI",Roboto,sans-serif;
49
+ }
50
+ @media (prefers-color-scheme:dark){:root{}}
51
+ :root[data-theme="dark"]{}
52
+ :root[data-theme="light"]{
53
+ --bg:#EDEAF6;
54
+ --grad: radial-gradient(1000px 640px at 10% -10%, rgba(255,200,90,0.28), transparent 60%),
55
+ radial-gradient(900px 600px at 104% -6%, rgba(120,150,255,0.22), transparent 58%),
56
+ linear-gradient(160deg,#F3EFFB 0%,#ECEAF7 50%,#F5F0E4 100%);
57
+ --panel: rgba(255,255,255,0.62); --panel-2: rgba(255,255,255,0.78); --panel-solid:#FBF7EE;
58
+ --glass-border: rgba(90,70,20,0.16); --glass-hi: rgba(255,255,255,0.7);
59
+ --border: rgba(90,70,20,0.16); --border-strong: rgba(185,120,22,0.55);
60
+ --ink:#241A08; --muted:#6E5A3E;
61
+ --gold:#8A5A0C; --gold-bright:#B9781A;
62
+ --ok:#1F7A4C; --ok-bg:rgba(180,220,190,0.6); --warn:#8A6410; --warn-bg:rgba(240,225,180,0.7);
63
+ --bad:#B5382E; --bad-bg:rgba(240,205,200,0.7); --info:#1F6F9E; --info-bg:rgba(200,225,240,0.7);
64
+ --shadow: 0 10px 30px rgba(60,40,10,0.14);
65
+ }
66
+ *{box-sizing:border-box;margin:0;padding:0}
67
+ html,body{height:100%}
68
+ body{background:var(--bg);background-image:var(--grad);background-attachment:fixed;background-size:cover;
69
+ color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}
70
+ .mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}
71
+ .glass{background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);
72
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
73
+
74
+ /* top bar */
75
+ header.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;
76
+ padding:0.7rem 1.1rem;border-bottom:1px solid var(--glass-border);
77
+ background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
78
+ position:sticky;top:0;z-index:20;box-shadow:inset 0 -1px 0 var(--glass-hi);}
79
+ .brand{display:flex;align-items:center;gap:0.6rem;}
80
+ .livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 12px var(--ok);animation:pulse 2.4s infinite;flex:none;}
81
+ .livedot.stale{background:var(--muted);box-shadow:none;animation:none;}
82
+ @keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent),0 0 12px var(--ok)}70%{box-shadow:0 0 0 7px transparent,0 0 12px var(--ok)}100%{box-shadow:0 0 0 0 transparent,0 0 12px var(--ok)}}
83
+ @media (prefers-reduced-motion:reduce){.livedot{animation:none}}
84
+ .brand b{font-size:0.98rem;letter-spacing:-0.01em;}
85
+ .brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}
86
+ .chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}
87
+ .chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--glass-border);
88
+ background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}
89
+ .chip b{color:var(--ink);font-weight:600;}
90
+ .chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}
91
+ .themebtn{font-family:var(--mono);font-size:0.68rem;background:var(--panel-2);border:1px solid var(--glass-border);color:var(--muted);
92
+ border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}
93
+ .themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
94
+
95
+ /* layout */
96
+ .wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}
97
+ @media (max-width:920px){.wrap{grid-template-columns:1fr}}
98
+ section h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);
99
+ margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}
100
+ section h2 .count{color:var(--muted);}
101
+
102
+ /* fleet cards */
103
+ .fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}
104
+ .node{border-radius:13px;padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s,box-shadow .12s;
105
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
106
+ border:1px solid var(--glass-border);border-left:3px solid var(--muted);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
107
+ .node:hover{transform:translateY(-2px);border-color:var(--border-strong);}
108
+ .node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.66;}
109
+ .node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}
110
+ .node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
111
+ .node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}
112
+ .node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}
113
+ .node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}
114
+ .node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}
115
+ .node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);
116
+ border-radius:4px;padding:0 0.3em;}
117
+ .bar-track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-bottom:0.3rem;}
118
+ :root[data-theme="light"] .bar-track{background:rgba(90,70,20,0.14);}
119
+ .bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;box-shadow:0 0 8px color-mix(in srgb,var(--ok) 60%,transparent);}
120
+ .bar-fill.warn{background:var(--warn);box-shadow:0 0 8px color-mix(in srgb,var(--warn) 60%,transparent);}
121
+ .bar-fill.bad{background:var(--bad);box-shadow:0 0 8px color-mix(in srgb,var(--bad) 60%,transparent);}
122
+ .node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}
123
+ .caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}
124
+ .cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border:1px solid var(--glass-border);border-radius:5px;padding:0.05em 0.4em;}
125
+ .empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:11px;}
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
+
146
+ /* hardware meters */
147
+ .hw{border-radius:13px;padding:0.85rem;margin-top:1rem;
148
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
149
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
150
+ .meter{margin-bottom:0.65rem;}
151
+ .meter:last-child{margin-bottom:0;}
152
+ .meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}
153
+ .meter .lab b{color:var(--ink);}
154
+
155
+ /* activity feed */
156
+ aside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}
157
+ .feed{border-radius:13px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;
158
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
159
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
160
+ .line{padding:0.28rem 0.6rem;border-bottom:1px solid var(--glass-border);display:flex;gap:0.5rem;line-height:1.3;}
161
+ .line:last-child{border-bottom:none;}
162
+ .line .t{color:var(--muted);flex:none;}
163
+ .line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
164
+ .line .d{color:var(--ink);flex:1;word-break:break-word;}
165
+ .line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}
166
+
167
+ /* command bar */
168
+ footer.cmd{position:fixed;left:0;right:0;bottom:0;z-index:24;
169
+ background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
170
+ 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);}
171
+ footer .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}
172
+ footer .sel b{color:var(--ink);}
173
+ footer input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:rgba(0,0,0,0.28);color:var(--ink);
174
+ border:1px solid var(--glass-border);border-radius:9px;padding:0.5em 0.7em;}
175
+ :root[data-theme="light"] footer input{background:rgba(255,255,255,0.6);}
176
+ footer input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
177
+ button.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:9px;padding:0.5em 0.85em;
178
+ 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);}
179
+ button.act.ghost{background:var(--panel-2);color:var(--gold-bright);box-shadow:none;}
180
+ button.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}
181
+ button.act:disabled{opacity:0.5;cursor:default;}
182
+ #out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;z-index:30;
183
+ background:var(--panel-2);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);
184
+ border:1px solid var(--border-strong);border-radius:12px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;
185
+ white-space:pre-wrap;display:none;box-shadow:0 16px 44px rgba(0,0,0,0.5);}
186
+ #out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}
187
+ .offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;
188
+ border-bottom:1px solid var(--glass-border);}
189
+
190
+ /* tabs + product panes */
191
+ .tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);
192
+ background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
193
+ position:sticky;top:53px;z-index:19;}
194
+ .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;}
195
+ .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);}
196
+ .tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}
197
+ .pane{display:none;} .pane.active{display:block;}
198
+ .rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}
199
+ .rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}
200
+ .rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}
201
+ .rp-form{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.55rem;align-items:end;background:var(--panel-2);padding:0.7rem;border-radius:11px;border:1px solid var(--glass-border);margin:0.8rem 1.1rem;}
202
+ .rp-form h3{grid-column:1/-1;font-family:var(--mono);font-size:0.7rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--gold-bright);margin-bottom:0.2rem;}
203
+ .rp-field{display:flex;flex-direction:column;gap:0.25rem;}
204
+ .rp-field label{font-size:0.68rem;color:var(--muted);}
205
+ .rp-field input,.rp-field select{font-family:var(--mono);font-size:0.72rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:7px;padding:0.38rem 0.5rem;}
206
+ .rp-card{border-radius:13px;padding:0.75rem 0.85rem;border-left:3px solid var(--gold-bright);
207
+ background:var(--panel);-webkit-backdrop-filter:blur(14px) saturate(1.25);backdrop-filter:blur(14px) saturate(1.25);
208
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
209
+ .rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}
210
+ .rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}
211
+ .rp-m{display:flex;flex-direction:column;}
212
+ .rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}
213
+ .rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
214
+ .rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}
215
+ .rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}
216
+ .pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}
217
+ .pm .state{font-weight:700;letter-spacing:0.03em;}
218
+ .pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}
219
+ .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;}
220
+ .toggle::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}
221
+ .toggle[aria-checked="true"]{background:var(--ok-bg);border-color:var(--ok);}
222
+ .toggle[aria-checked="true"]::after{transform:translateX(20px);background:var(--ok);}
223
+ .toggle:disabled{opacity:0.45;cursor:default;}
224
+ .toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
225
+ .rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}
226
+ .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;}
227
+ .rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}
228
+ .rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}
229
+ .rp-btn:disabled{opacity:0.4;cursor:default;}
230
+ .rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
231
+ .rp-card .rp-char{font-family:var(--mono);font-size:0.72rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:7px;padding:0.35rem 0.5rem;width:100%;}
232
+ .rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}
233
+
234
+ /* ── Park (isometric live map) ── */
235
+ #pane-park{padding:0;}
236
+ .pk-stage{position:relative;height:calc(100vh - 148px);min-height:420px;overflow:hidden;}
237
+ #pk-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab;}
238
+ #pk-canvas:active{cursor:grabbing;}
239
+ .pk-feed{position:absolute;top:12px;right:12px;width:300px;max-height:calc(100% - 24px);z-index:6;border-radius:13px;overflow:hidden;
240
+ display:flex;flex-direction:column;background:var(--panel);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);
241
+ border:1px solid var(--glass-border);box-shadow:var(--shadow),inset 0 1px 0 var(--glass-hi);}
242
+ .pk-feed h3{font-family:var(--mono);font-size:0.6rem;letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-bright);
243
+ padding:0.6rem 0.75rem 0.45rem;border-bottom:1px solid var(--glass-border);display:flex;justify-content:space-between;}
244
+ .pk-feed .rows{overflow-y:auto;padding:0.25rem;}
245
+ .pk-row{display:flex;gap:0.5rem;align-items:baseline;padding:0.28rem 0.45rem;border-radius:7px;font-size:0.72rem;}
246
+ .pk-row:hover{background:color-mix(in srgb,var(--gold-bright) 8%,transparent);}
247
+ .pk-row .t{font-family:var(--mono);font-size:0.6rem;color:var(--muted);flex:none;}
248
+ .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;}
249
+ .pk-row .msg{color:var(--ink);opacity:0.92;line-height:1.32;flex:1;word-break:break-word;}
250
+ .pk-row .k{width:6px;height:6px;border-radius:50%;flex:none;margin-top:0.35em;}
251
+ .pk-k-ev{background:var(--info);} .pk-k-rt{background:var(--gold-bright);} .pk-k-hw{background:var(--muted);}
252
+ .pk-k-hb{background:var(--ok);} .pk-k-tool{background:var(--gold);}
253
+ .pk-hint{position:absolute;left:12px;bottom:12px;z-index:6;font-family:var(--mono);font-size:0.64rem;color:var(--muted);
254
+ 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);}
255
+ .pk-hint b{color:var(--gold-bright);}
256
+ .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);}
257
+ .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;}
258
+ .pk-legend i{width:8px;height:8px;border-radius:2px;}
259
+
260
+ /* robot drawer */
261
+ .pk-drawer{position:fixed;top:0;right:0;height:100%;width:min(430px,94vw);z-index:40;
262
+ background:linear-gradient(180deg,var(--panel-2),var(--panel));-webkit-backdrop-filter:blur(22px) saturate(1.3);backdrop-filter:blur(22px) saturate(1.3);
263
+ border-left:1px solid var(--border-strong);box-shadow:-22px 0 60px rgba(0,0,0,0.5);
264
+ transform:translateX(105%);transition:transform .32s cubic-bezier(.2,.8,.2,1);display:flex;flex-direction:column;}
265
+ .pk-drawer.open{transform:translateX(0);}
266
+ .pk-dh{display:flex;align-items:center;gap:0.6rem;padding:0.9rem 1rem;border-bottom:1px solid var(--glass-border);}
267
+ .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));
268
+ border:1px solid var(--border-strong);display:grid;place-items:center;font-size:1.2rem;}
269
+ .pk-dh .nm{font-size:1.02rem;font-weight:700;font-family:var(--mono);}
270
+ .pk-dh .sub{font-family:var(--mono);font-size:0.64rem;color:var(--muted);}
271
+ .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;}
272
+ .pk-dh .x:hover{color:var(--ink);border-color:var(--border-strong);}
273
+ .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;}
274
+ .pk-pill.live{color:var(--ok);border-color:var(--ok);background:var(--ok-bg);}
275
+ .pk-pill.idle{color:var(--muted);border-color:var(--glass-border);}
276
+ .pk-dbody{overflow-y:auto;padding:1rem;display:flex;flex-direction:column;gap:0.9rem;}
277
+ .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);}
278
+ .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;}
279
+ .pk-cam{position:relative;border-radius:11px;overflow:hidden;border:1px solid var(--border-strong);aspect-ratio:16/9;background:#05060a;}
280
+ .pk-cam canvas,.pk-cam video{width:100%;height:100%;display:block;object-fit:cover;}
281
+ .pk-cam .hud{position:absolute;inset:0;pointer-events:none;font-family:var(--mono);font-size:0.6rem;}
282
+ .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;}
283
+ .pk-cam .rec i{width:8px;height:8px;border-radius:50%;background:#FF5B4A;box-shadow:0 0 8px #FF5B4A;animation:pulse 1.1s infinite;}
284
+ .pk-cam .tr{position:absolute;top:8px;right:8px;color:#bcd;text-shadow:0 1px 3px #000;}
285
+ .pk-cam .br{position:absolute;bottom:8px;left:8px;color:#bcd;text-shadow:0 1px 3px #000;}
286
+ .pk-meter{display:flex;align-items:center;gap:0.6rem;margin:0.35rem 0;}
287
+ .pk-meter .lab{font-family:var(--mono);font-size:0.64rem;color:var(--muted);width:60px;flex:none;}
288
+ .pk-bars{flex:1;height:16px;display:flex;gap:2px;align-items:flex-end;}
289
+ .pk-bars i{flex:1;background:var(--glass-border);border-radius:1px;transition:height .1s,background .1s;}
290
+ .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;}
291
+ .pk-wave{height:32px;width:100%;}
292
+ .pk-hwgrid{display:grid;grid-template-columns:1fr 1fr;gap:0.55rem 0.8rem;}
293
+ .pk-hwgrid .l{font-family:var(--mono);font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
294
+ .pk-hwgrid .track{height:6px;border-radius:4px;background:rgba(0,0,0,0.28);overflow:hidden;margin-top:3px;}
295
+ .pk-hwgrid .fill{height:100%;background:var(--gold-bright);border-radius:4px;transition:width .3s;}
296
+ .pk-grid2{display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;}
297
+ .pk-kv{background:var(--panel-2);border:1px solid var(--glass-border);border-radius:9px;padding:0.5rem 0.6rem;}
298
+ .pk-kv .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-variant-numeric:tabular-nums;}
299
+ .pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}
300
+ .pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
301
+ .pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
302
+ .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
303
+ </style>
304
+ </head>
305
+ <body>
306
+ <div class="offline" id="offline">connection lost — retrying</div>
307
+ <header class="bar">
308
+ <div class="brand">
309
+ <span class="livedot" id="livedot"></span>
310
+ <div>
311
+ <b>RoboPark Control</b>
312
+ <div class="who mono" id="who">connecting…</div>
313
+ </div>
314
+ </div>
315
+ <div class="chips" id="summary"></div>
316
+ <button class="themebtn" id="theme">Theme</button>
317
+ </header>
318
+
319
+ <div class="tabs">
320
+ <button class="tab active" data-tab="fleet" id="tabbtn-fleet">Fleet</button>
321
+ <button class="tab" data-tab="park" id="tabbtn-park">Park</button>
322
+ <button class="tab" data-tab="robopark" id="tabbtn-robopark">Sessions &amp; Telemetry</button>
323
+ </div>
324
+
325
+ <div class="pane active" id="pane-fleet">
326
+ <div class="wrap">
327
+ <section>
328
+ <h2>Fleet <span class="count" id="fleetcount"></span></h2>
329
+ <div class="fleet" id="fleet"></div>
330
+ <div class="hw" id="hw"></div>
331
+ </section>
332
+ <aside>
333
+ <h2>Live activity</h2>
334
+ <div class="feed" id="feed"></div>
335
+ </aside>
336
+ </div>
337
+ </div>
338
+
339
+ <div class="pane" id="pane-park">
340
+ <div class="pk-stage" id="pk-stage">
341
+ <canvas id="pk-canvas"></canvas>
342
+ <div class="pk-legend">
343
+ <span><i style="background:var(--gold-bright)"></i>hub</span>
344
+ <span><i style="background:var(--ok);border-radius:50%"></i>robot</span>
345
+ <span><i style="background:var(--info)"></i>peer</span>
346
+ <span><i style="background:var(--info)"></i>dispatch</span>
347
+ <span><i style="background:var(--ok)"></i>telemetry</span>
348
+ </div>
349
+ <aside class="pk-feed">
350
+ <h3><span>Live activity</span><span style="color:var(--muted)" id="pk-src">/fed/stream</span></h3>
351
+ <div class="rows" id="pk-rows"></div>
352
+ </aside>
353
+ <div class="pk-hint">drag to pan · scroll to zoom · <b>click a robot</b> for cam / mic / speaker</div>
354
+ </div>
355
+ <div class="pk-drawer" id="pk-drawer">
356
+ <div class="pk-dh">
357
+ <div class="pk-av" id="pk-av">🤖</div>
358
+ <div><div class="nm" id="pk-name">robot</div><div class="sub" id="pk-sub">satellite</div></div>
359
+ <span class="pk-pill idle" id="pk-state" style="margin-left:0.4rem">idle</span>
360
+ <button class="x" id="pk-close">✕</button>
361
+ </div>
362
+ <div class="pk-dbody" id="pk-body"></div>
363
+ </div>
364
+ </div>
365
+
366
+ <div class="pane" id="pane-robopark">
367
+ <div class="rp-head">
368
+ <h2 style="margin:0">RoboPark · sessions &amp; telemetry</h2>
369
+ <div class="rp-controls">
370
+ <span class="pm" id="pm-wrap" hidden>
371
+ <span>production mode</span>
372
+ <button class="toggle" id="pm-toggle" role="switch" aria-checked="false" title="master enable — robots only join sessions when ON"></button>
373
+ <span class="state off" id="pm-state">off</span>
374
+ </span>
375
+ <div class="rp-totals" id="rp-totals"></div>
376
+ </div>
377
+ </div>
378
+
379
+ <!-- Add LiveKit server -->
380
+ <div class="rp-form" id="rp-addserver">
381
+ <h3>Add LiveKit server</h3>
382
+ <div class="rp-field"><label>ID</label><input id="srv-id" placeholder="livekit-1-server"></div>
383
+ <div class="rp-field"><label>Name</label><input id="srv-name" placeholder="LiveKit Server 1"></div>
384
+ <div class="rp-field"><label>URL (ws:// or wss://)</label><input id="srv-url" placeholder="ws://livekit-1.tail2086c6.ts.net:7880"></div>
385
+ <div class="rp-field"><label>API key</label><input id="srv-key" placeholder="devkey"></div>
386
+ <div class="rp-field"><label>API secret</label><input type="password" id="srv-secret" placeholder="secret"></div>
387
+ <div class="rp-field"><label>Max sessions</label><input id="srv-max" type="number" value="8"></div>
388
+ <button class="rp-btn" id="srv-add" style="height:fit-content">Add server</button>
389
+ </div>
390
+
391
+ <!-- Add robot / device -->
392
+ <div class="rp-form" id="rp-addrobot">
393
+ <h3>Add robot (mint enrollment token)</h3>
394
+ <div class="rp-field"><label>Robot name</label><input id="rb-name" placeholder="jaguar"></div>
395
+ <div class="rp-field"><label>Character preset</label><select id="rb-char"><option value="">— auto —</option></select></div>
396
+ <div class="rp-field"><label>Tailscale IP (optional)</label><input id="rb-tsip" placeholder="100.x.x.x"></div>
397
+ <div class="rp-field"><label>LAN IP (optional)</label><input id="rb-lanip" placeholder="192.168.x.x"></div>
398
+ <button class="rp-btn" id="rb-add" style="height:fit-content">Add robot</button>
399
+ </div>
400
+
401
+ <div class="rp-grid" id="rp-grid"></div>
402
+ </div>
403
+
404
+ <footer class="cmd">
405
+ <span class="sel">on <b id="selName">this node</b></span>
406
+ <input id="cmdInput" placeholder="/status /nodes /health (Enter to run)" autocomplete="off" spellcheck="false">
407
+ <button class="act" id="runBtn">Run</button>
408
+ <button class="act ghost" id="applyBtn">Apply config to fleet</button>
409
+ <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>
410
+ </footer>
411
+ <div id="out"><button class="x" id="outClose">close</button><span id="outText"></span></div>
412
+
413
+ <script>
414
+ (function(){
415
+ var TOKEN = new URLSearchParams(location.search).get('token') || '';
416
+ var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
417
+ var selfId = null, selected = null, lastSeq = -1, everOk = false;
418
+ var rpReadOnly = false, pmBusy = false;
419
+ var lastStatus = null; // most recent /fed/status snapshot (shared with Park)
420
+ var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)
421
+
422
+ var $ = function(id){ return document.getElementById(id); };
423
+ function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }
424
+ 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]; }
425
+ function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }
426
+ 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()); }
427
+
428
+ // theme (dark glass is the default; toggle to frosted light)
429
+ (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}
430
+ document.documentElement.setAttribute('data-theme', s || 'dark');
431
+ $('theme').addEventListener('click', function(){
432
+ var cur=document.documentElement.getAttribute('data-theme')||'dark';
433
+ var nx=cur==='dark'?'light':'dark';
434
+ document.documentElement.setAttribute('data-theme', nx);
435
+ try{localStorage.setItem(k,nx)}catch(e){}
436
+ });
437
+ })();
438
+
439
+ function detailOf(data){
440
+ var p = data || {};
441
+ if(typeof p==='string') return p;
442
+ if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' ✓':' ✗'):'');
443
+ if(p.model) return String(p.model);
444
+ if(p.provider) return String(p.provider);
445
+ if(p.reason) return String(p.reason).slice(0,60);
446
+ if(p.description) return String(p.description).slice(0,50);
447
+ if(p.name) return String(p.name);
448
+ return '';
449
+ }
450
+ function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }
451
+
452
+ function summarizeFrame(f){
453
+ var d = f.d || {};
454
+ if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% · mem '+m+'%'; }
455
+ if(f.t==='hb'){ return 'heartbeat · load '+(d.load!=null?d.load:'-'); }
456
+ if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }
457
+ if(f.t==='rt'){ var rp=d.d||{}; return 'route → '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }
458
+ if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }
459
+ if(f.t==='log'){
460
+ if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);
461
+ return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' ×'+d.count):''); }
462
+ return 'log';
463
+ }
464
+ return f.t;
465
+ }
466
+
467
+ function renderFeed(activity){
468
+ var feed = $('feed');
469
+ if(!activity || !activity.length){
470
+ if(!feed.dataset.seeded){ feed.innerHTML = '<div class="line"><span class="d" style="color:var(--muted)">waiting for fleet activity — task runs, routing, and peer events stream here.</span></div>'; }
471
+ return;
472
+ }
473
+ feed.dataset.seeded = '1';
474
+ var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;
475
+ var html = '';
476
+ for(var i=0;i<activity.length;i++){
477
+ var e = activity[i]; var f = e.f || e;
478
+ var name = (f.n||'').slice(0,10);
479
+ html += '<div class="line k-'+esc(f.t)+'"><span class="t">'+clock(f.ts)+'</span>'
480
+ + '<span class="n" title="'+esc(f.n)+'">'+esc(name)+'</span>'
481
+ + '<span class="d">'+esc(summarizeFrame(f))+'</span></div>';
482
+ }
483
+ feed.innerHTML = html;
484
+ if(near) feed.scrollTop = feed.scrollHeight;
485
+ }
486
+
487
+ function meter(label, value, sub){
488
+ var v = Math.max(0, Math.min(100, Math.round(value||0)));
489
+ return '<div class="meter"><div class="lab"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'
490
+ + (sub?(' '+esc(sub)):'')+'</span></div>'
491
+ + '<div class="bar-track"><div class="bar-fill '+loadClass(v)+'" style="width:'+v+'%"></div></div></div>';
492
+ }
493
+
494
+ // ── per-node hardware store + rolling history (drives the Fleet cards + sparklines) ──
495
+ var hwByNode = {}; // nodeId -> latest HardwareSnapshot (self from /fed/status.hardware, peers from 'hw' frames)
496
+ var hwHist = {}; // nodeId -> { cpu:[], temp:[], net:[] } rolling buffers
497
+ var HWMAX = 48; // ~2.5 min of history at the 3s poll cadence
498
+ function netTotal(hw){
499
+ 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;
500
+ }
501
+ function netRxTx(hw){
502
+ 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};
503
+ }
504
+ function pushHist(id, snap){
505
+ var h = hwHist[id] || (hwHist[id]={cpu:[],temp:[],net:[]});
506
+ var cpu = snap&&snap.cpu?Math.round(snap.cpu.percent):0;
507
+ var temp = snap&&snap.temperatureC!=null?snap.temperatureC:null;
508
+ h.cpu.push(cpu); if(h.cpu.length>HWMAX) h.cpu.shift();
509
+ h.temp.push(temp); if(h.temp.length>HWMAX) h.temp.shift();
510
+ h.net.push(netTotal(snap)); if(h.net.length>HWMAX) h.net.shift();
511
+ }
512
+ function updateHw(s){
513
+ if(s.self && s.hardware){ hwByNode[s.self.nodeId] = s.hardware; }
514
+ var act = s.activity||[];
515
+ 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; } }
516
+ var nodes = s.nodes||[];
517
+ for(var j=0;j<nodes.length;j++){ if(nodes[j].connected) pushHist(nodes[j].nodeId, hwByNode[nodes[j].nodeId]); }
518
+ }
519
+ // Inline sparkline (SVG polyline) from a numeric history buffer.
520
+ function sparkSvg(arr, color, maxv){
521
+ if(!arr || arr.length<2) return '';
522
+ var w=100,h=20,n=arr.length, vals=[];
523
+ for(var i=0;i<n;i++) vals.push(arr[i]==null?0:arr[i]);
524
+ var mx = maxv || Math.max.apply(null, vals); if(mx<=0) mx=1;
525
+ 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); }
526
+ return '<svg class="spark" viewBox="0 0 '+w+' '+h+'" preserveAspectRatio="none" aria-hidden="true">'
527
+ + '<polyline points="'+pts+'" fill="none" stroke="'+color+'" stroke-width="1.6" stroke-linejoin="round"/></svg>';
528
+ }
529
+ function fmtRate(b){ return fmtBytes(b)+'/s'; }
530
+ 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'; }
531
+ function tempClass(t){ return t==null?'':(t>=75?'bad':(t>=65?'warn':'ok')); }
532
+
533
+ function renderHw(hw){
534
+ var el = $('hw');
535
+ if(!hw){ el.innerHTML = '<div class="meter"><div class="lab"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }
536
+ var html = '<h2 style="margin-top:0">This node · '+esc(hw.system?hw.system.hostname:'')+'</h2>';
537
+ html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');
538
+ html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');
539
+ 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'):''); }
540
+ if(hw.temperatureC!=null){ html += meter('Temp', Math.min(100,(hw.temperatureC/90)*100), Math.round(hw.temperatureC)+'°C'); }
541
+ 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)); }
542
+ var nt=netRxTx(hw); html += '<div class="stats" style="margin-top:0.55rem">'
543
+ + '<span class="rx">↓ <b>'+fmtRate(nt.rx)+'</b></span><span class="tx">↑ <b>'+fmtRate(nt.tx)+'</b></span>'
544
+ + (hw.system?('<span>up <b>'+fmtUptime(hw.system.uptimeSec)+'</b></span>'):'') + '</div>';
545
+ el.innerHTML = html;
546
+ }
547
+
548
+ function selectNode(id, name){
549
+ selected = (id===selfId)?null:id;
550
+ $('selName').textContent = selected ? name : 'this node';
551
+ var cards = document.querySelectorAll('.node');
552
+ for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);
553
+ }
554
+
555
+ function mrow(label, pct, valTxt, cls){
556
+ var v = Math.max(0, Math.min(100, Math.round(pct||0)));
557
+ return '<div class="mrow"><span class="ml">'+label+'</span>'
558
+ + '<div class="bar-track"><div class="bar-fill '+loadClass(v)+'" style="width:'+v+'%"></div></div>'
559
+ + '<span class="mv '+(cls||'')+'">'+esc(valTxt)+'</span></div>';
560
+ }
561
+ function renderFleet(nodes){
562
+ var el = $('fleet');
563
+ 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; }
564
+ // connected first, then robots/satellites above hubs so the fleet reads robot-first
565
+ nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });
566
+ var accent = '#6FE0A6';
567
+ try{ accent = getComputedStyle(document.documentElement).getPropertyValue('--ok').trim() || accent; }catch(e){}
568
+ var html = '';
569
+ for(var i=0;i<nodes.length;i++){
570
+ var n = nodes[i];
571
+ var isSelf = n.nodeId===selfId;
572
+ var up = !!n.connected;
573
+ var hw = hwByNode[n.nodeId] || null;
574
+ var hist = hwHist[n.nodeId] || {};
575
+ var cpu = hw&&hw.cpu ? Math.round(hw.cpu.percent) : (n.load!=null?Math.round(n.load):0);
576
+ var cores = hw&&hw.cpu ? hw.cpu.cores : null;
577
+ var mem = hw&&hw.memory ? Math.round(hw.memory.percent) : null;
578
+ var temp = hw&&hw.temperatureC!=null ? hw.temperatureC : null;
579
+ var disk = (hw&&hw.disk&&hw.disk.mounts&&hw.disk.mounts[0]) ? hw.disk.mounts[0].percent : null;
580
+ var nt = netRxTx(hw);
581
+ var upt = hw&&hw.system ? hw.system.uptimeSec : null;
582
+ var caps = (n.capabilities||[]).filter(function(c){ return c.indexOf('backend:')!==0; }).slice(0,5);
583
+ var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class="cap">'+esc(caps[c])+'</span>';
584
+ var site = n.site?(' · '+esc(n.site)):'';
585
+
586
+ 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)+'">'
587
+ + '<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>'
588
+ + '<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>';
589
+
590
+ if(up){
591
+ html += mrow('CPU', cpu, cpu+'%'+(cores?(' /'+cores+'c'):''), loadClass(cpu))
592
+ + (hist.cpu&&hist.cpu.length>1 ? sparkSvg(hist.cpu, accent, 100) : '')
593
+ + (mem!=null ? mrow('MEM', mem, mem+'%', loadClass(mem)) : '')
594
+ + (temp!=null ? mrow('TEMP', Math.min(100,(temp/90)*100), Math.round(temp)+'°C', tempClass(temp)) : '')
595
+ + '<div class="stats">'
596
+ + '<span class="rx">↓ <b>'+fmtRate(nt.rx)+'</b></span>'
597
+ + '<span class="tx">↑ <b>'+fmtRate(nt.tx)+'</b></span>'
598
+ + (disk!=null?('<span>disk <b>'+disk+'%</b></span>'):'')
599
+ + (upt!=null?('<span>up <b>'+fmtUptime(upt)+'</b></span>'):'')
600
+ + '<span><b>'+((n.commands||[]).length)+'</b> cmds</span>'
601
+ + '</div>';
602
+ } else {
603
+ html += '<div class="stats"><span style="color:var(--muted)">offline'+(n.lastSeenAt?(' · last seen '+clock(n.lastSeenAt)):'')+'</span></div>';
604
+ }
605
+ html += (capHtml?('<div class="caps">'+capHtml+'</div>'):'') + '</div>';
606
+ }
607
+ el.innerHTML = html;
608
+ var cards = el.querySelectorAll('.node');
609
+ for(var j=0;j<cards.length;j++){
610
+ (function(card){
611
+ function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }
612
+ card.addEventListener('click', pick);
613
+ card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });
614
+ })(cards[j]);
615
+ }
616
+ }
617
+
618
+ function renderSummary(s){
619
+ var nodes = s.nodes||[];
620
+ var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;
621
+ var down = nodes.length-up;
622
+ var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;
623
+ var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});
624
+ var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;
625
+ // live aggregates + alerts from per-node hardware
626
+ var hotTemp=null, maxCpu=0, alerts=[];
627
+ for(var k=0;k<nodes.length;k++){
628
+ var nn=nodes[k]; if(!nn.connected) continue;
629
+ var hw=hwByNode[nn.nodeId]; if(!hw) continue;
630
+ if(hw.cpu&&hw.cpu.percent>maxCpu) maxCpu=Math.round(hw.cpu.percent);
631
+ if(hw.temperatureC!=null&&(hotTemp==null||hw.temperatureC>hotTemp)) hotTemp=hw.temperatureC;
632
+ if(hw.temperatureC!=null&&hw.temperatureC>=75) alerts.push(nn.displayName+' '+Math.round(hw.temperatureC)+'°C');
633
+ if(hw.cpu&&hw.cpu.percent>=92) alerts.push(nn.displayName+' cpu '+Math.round(hw.cpu.percent)+'%');
634
+ if(hw.memory&&hw.memory.percent>=92) alerts.push(nn.displayName+' mem '+Math.round(hw.memory.percent)+'%');
635
+ 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+'%');
636
+ }
637
+ if(down>0) alerts.unshift(down+' offline');
638
+ var html = '<span class="chip ok">up <b>'+up+'</b></span>'
639
+ + '<span class="chip'+(down?' bad':'')+'">down <b>'+down+'</b></span>'
640
+ + '<span class="chip info">runs <b>'+runs+'</b></span>'
641
+ + '<span class="chip">avg load <b>'+avg+'%</b></span>'
642
+ + '<span class="chip">peak cpu <b>'+maxCpu+'%</b></span>'
643
+ + (hotTemp!=null?('<span class="chip">hottest <b>'+Math.round(hotTemp)+'°C</b></span>'):'');
644
+ for(var a2=0;a2<alerts.length&&a2<4;a2++){ html += '<span class="chip alert">⚠ <b>'+esc(alerts[a2])+'</b></span>'; }
645
+ $('summary').innerHTML = html;
646
+ $('fleetcount').textContent = '(' + nodes.length + ')';
647
+ var self = s.self||{};
648
+ var role = s.role||{};
649
+ $('who').textContent = (self.displayName||'node') + ' · ' + (self.role||'?') + (role.site?(' · '+role.site):'') + ' · v' + (self.version||'?');
650
+ }
651
+
652
+ function setLive(ok){
653
+ $('livedot').classList.toggle('stale', !ok);
654
+ $('offline').style.display = (!ok && everOk) ? 'block' : 'none';
655
+ }
656
+
657
+ function poll(){
658
+ fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){
659
+ if(!r.ok) throw new Error('status '+r.status);
660
+ return r.json();
661
+ }).then(function(s){
662
+ everOk = true; setLive(true); lastStatus = s;
663
+ if(!selfId && s.self) selfId = s.self.nodeId;
664
+ if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
665
+ updateHw(s);
666
+ renderSummary(s);
667
+ renderFleet(s.nodes||[]);
668
+ renderHw(s.hardware);
669
+ renderFeed(s.activity||[]);
670
+ Park.onStatus(s);
671
+ }).catch(function(){ setLive(false); });
672
+ }
673
+
674
+ function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }
675
+ $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });
676
+
677
+ function runCommand(input){
678
+ input = (input||'').trim();
679
+ if(!input) return;
680
+ var payload = selected ? ('/node ' + selected + ' ' + input) : input;
681
+ $('runBtn').disabled = true;
682
+ fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })
683
+ .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })
684
+ .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })
685
+ .catch(function(e){ showOut('request failed: '+e.message); })
686
+ .then(function(){ $('runBtn').disabled=false; });
687
+ }
688
+
689
+ $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });
690
+ $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });
691
+ $('applyBtn').addEventListener('click', function(){
692
+ $('applyBtn').disabled = true;
693
+ fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })
694
+ .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })
695
+ .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })
696
+ .catch(function(e){ showOut('apply failed: '+e.message); })
697
+ .then(function(){ $('applyBtn').disabled=false; });
698
+ });
699
+ $('updateBtn').addEventListener('click', function(){
700
+ 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;
701
+ $('updateBtn').disabled = true;
702
+ showOut('Updating fleet — installing latest on each node…');
703
+ fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input:'/update all' }) })
704
+ .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })
705
+ .then(function(res){ showOut('Update fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); poll(); })
706
+ .catch(function(e){ showOut('update failed: '+e.message); })
707
+ .then(function(){ $('updateBtn').disabled=false; });
708
+ });
709
+
710
+ // ── tabs: Fleet + Park + RoboPark sessions/telemetry ──
711
+ var tabs = document.querySelectorAll('.tab');
712
+ var activeTab = 'fleet';
713
+ for (var ti=0; ti<tabs.length; ti++){
714
+ (function(btn){
715
+ btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });
716
+ })(tabs[ti]);
717
+ }
718
+ function selectTab(name){
719
+ activeTab = name;
720
+ for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);
721
+ $('pane-fleet').classList.toggle('active', name==='fleet');
722
+ $('pane-park').classList.toggle('active', name==='park');
723
+ $('pane-robopark').classList.toggle('active', name==='robopark');
724
+ if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
725
+ if (name==='robopark') pollRobopark();
726
+ if (name==='park') Park.activate(); else Park.deactivate();
727
+ }
728
+ var initHash = location.hash.replace('#','');
729
+ // Defer tab init until Park module is defined (same outer IIFE, later in source).
730
+ function initTab(){
731
+ if (initHash==='robopark'||initHash==='park') selectTab(initHash);
732
+ }
733
+ // initTab() is called at the very bottom of this IIFE, after Park is assigned.
734
+
735
+ function robotsFrom(d){
736
+ if (Array.isArray(d)) return d;
737
+ if (d && Array.isArray(d.robots)) return d.robots;
738
+ return [];
739
+ }
740
+ function applyReadOnly(){
741
+ var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;
742
+ var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;
743
+ var note=$('rp-ro-note');
744
+ if(rpReadOnly && !note){
745
+ note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';
746
+ note.textContent='read-only (no token) — start the hub with --token to enable control';
747
+ var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);
748
+ } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }
749
+ }
750
+
751
+ function rpAction(method, path, body){
752
+ if(rpReadOnly){ showOut('This node is read-only — restart the hub with --token to enable control actions.'); return Promise.resolve(null); }
753
+ return fetch(path+QS, {
754
+ method: method, cache:'no-store',
755
+ headers: body?{'content-type':'application/json'}:undefined,
756
+ body: body?JSON.stringify(body):undefined
757
+ }).then(function(r){
758
+ if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }
759
+ 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; });
760
+ });
761
+ }
762
+
763
+ function setPm(on){
764
+ var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');
765
+ if(!wrap) return;
766
+ wrap.hidden=false;
767
+ tg.setAttribute('aria-checked', on?'true':'false');
768
+ st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');
769
+ }
770
+ (function(){
771
+ var tg=$('pm-toggle'); if(!tg) return;
772
+ tg.addEventListener('click', function(){
773
+ if(rpReadOnly||pmBusy) return;
774
+ var next = tg.getAttribute('aria-checked')!=='true';
775
+ pmBusy=true; tg.disabled=true;
776
+ rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){
777
+ setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);
778
+ }).catch(function(e){ showOut('production_mode: '+e.message); })
779
+ .then(function(){ pmBusy=false; applyReadOnly(); });
780
+ });
781
+ })();
782
+ function pollSettings(){
783
+ fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){
784
+ if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);
785
+ }).catch(function(){});
786
+ }
787
+
788
+ function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }
789
+ function endSession(id){
790
+ if(!confirm('End the active session for '+id+'?')) return;
791
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){
792
+ showOut('ended session for '+id); pollRobopark(); Park.refreshDrawer();
793
+ }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });
794
+ }
795
+ $('rp-grid').addEventListener('click', function(e){
796
+ var b=e.target.closest?e.target.closest('.rp-btn'):null;
797
+ if(b){ var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');
798
+ if(act==='end') endSession(id);
799
+ if(act==='trigger') triggerSession(id);
800
+ if(act==='preview'){ selectTab('park'); Park.openDrawerForRobot(id); }
801
+ return;
802
+ }
803
+ var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;
804
+ if(s.dataset.changed) return;
805
+ var sid=s.getAttribute('data-robot'), cid=s.value;
806
+ if(!sid || cid===s.dataset.current) return;
807
+ updateRobotCharacter(sid, cid);
808
+ });
809
+ $('rp-grid').addEventListener('change', function(e){
810
+ var s=e.target.closest?e.target.closest('.rp-char'):null; if(!s) return;
811
+ var sid=s.getAttribute('data-robot'), cid=s.value;
812
+ if(cid===s.dataset.current) return;
813
+ updateRobotCharacter(sid, cid);
814
+ });
815
+
816
+ var characterPresets=[];
817
+ function loadCharacterPresets(){
818
+ fetch('/robopark/api/character-presets'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){ characterPresets=Array.isArray(d)?d:(d&&d.presets)||[]; fillCharacterDropdowns(); }).catch(function(){});
819
+ }
820
+ function fillCharacterDropdowns(){
821
+ var sels=document.querySelectorAll('.rp-char');
822
+ for(var i=0;i<sels.length;i++){ var sel=sels[i]; var cur=sel.dataset.current||'';
823
+ var opts=sel.querySelectorAll('option'); for(var k=opts.length-1;k>=0;k--){ if(opts[k].value) opts[k].parentNode.removeChild(opts[k]); }
824
+ for(var j=0;j<characterPresets.length;j++){ var p=characterPresets[j];
825
+ var o=document.createElement('option'); o.value=p.id||p.preset_id||p.character_id||''; o.textContent=(p.name||p.id||p.preset_id||'preset')+(p.description?(' '+p.description):''); if((o.value)===cur) o.selected=true; sel.appendChild(o);
826
+ }
827
+ }
828
+ }
829
+
830
+ function addServer(){
831
+ var body={
832
+ id:$('srv-id').value.trim(), name:$('srv-name').value.trim(), url:$('srv-url').value.trim(),
833
+ api_key:$('srv-key').value.trim(), api_secret:$('srv-secret').value,
834
+ max_sessions:parseInt($('srv-max').value,10)||8
835
+ };
836
+ if(!body.id||!body.name||!body.url){ showOut('Server ID, name, and URL are required'); return; }
837
+ body.webhook_url=body.url;
838
+ rpAction('POST','/robopark/api/servers',body).then(function(j){ showOut('server added: '+esc((j&&j.id)||body.id)); clearServerForm(); }).catch(function(e){ showOut('add server: '+e.message); });
839
+ }
840
+ function clearServerForm(){ $('srv-id').value=''; $('srv-name').value=''; $('srv-url').value=''; $('srv-key').value=''; $('srv-secret').value=''; $('srv-max').value='8'; }
841
+ function addRobot(){
842
+ var body={
843
+ name:$('rb-name').value.trim(), character_id:$('rb-char').value||null,
844
+ tailscale_ip:$('rb-tsip').value.trim()||null, lan_ip:$('rb-lanip').value.trim()||null
845
+ };
846
+ if(!body.name){ showOut('Robot name is required'); return; }
847
+ rpAction('POST','/robopark/api/devices',body).then(function(j){ showOut('robot added: '+esc((j&&j.name)||body.name)+'\\ntoken: '+esc((j&&j.enrollment_token)||'(none)')); clearRobotForm(); pollRobopark(); }).catch(function(e){ showOut('add robot: '+e.message); });
848
+ }
849
+ function clearRobotForm(){ $('rb-name').value=''; $('rb-char').value=''; $('rb-tsip').value=''; $('rb-lanip').value=''; }
850
+ function triggerSession(id){
851
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/trigger-session',{confidence:0.9}).then(function(){ showOut('triggered motion for '+id); pollRobopark(); Park.refreshDrawer(); }).catch(function(e){ showOut('trigger-session ('+id+'): '+e.message); });
852
+ }
853
+ function updateRobotCharacter(id, cid){
854
+ if(!cid){ showOut('Choose a character preset'); return; }
855
+ rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(id)+'/voice-config',{character_id:cid}).then(function(){ showOut('updated '+id+' character to '+cid); pollRobopark(); }).catch(function(e){ showOut('voice-config ('+id+'): '+e.message); });
856
+ }
857
+ $('srv-add').addEventListener('click', addServer);
858
+ $('rb-add').addEventListener('click', addRobot);
859
+
860
+ function renderRobotCardSelect(name, current){
861
+ var sel=$('rp-char-'+name); if(!sel) return;
862
+ sel.dataset.current=current||'';
863
+ fillCharacterDropdowns();
864
+ }
865
+
866
+ function pollRobopark(){
867
+ pollSettings();
868
+ loadCharacterPresets();
869
+ fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
870
+ var grid = $('rp-grid'), totals = $('rp-totals');
871
+ // stash per-robot rows so the Park drawer can show live session stats
872
+ roboparkByName = {};
873
+ robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });
874
+ if (d && (d.disabled || d.ok===false)){
875
+ totals.innerHTML=''; $('pm-wrap').hidden=true;
876
+ grid.innerHTML = '<div class="empty" style="grid-column:1/-1">'+esc(d.error||'no scheduler linked')+' — start the hub with <span class="mono">--scheduler-url http://&lt;scheduler&gt;:8080</span></div>';
877
+ return;
878
+ }
879
+ var robots = robotsFrom(d);
880
+ var tot = (d && d.totals) || {};
881
+ var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);
882
+ var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);
883
+ totals.innerHTML =
884
+ '<span class="chip">robots <b>'+robots.length+'</b></span>'
885
+ + '<span class="chip">sessions <b>'+totalSessions+'</b></span>'
886
+ + '<span class="chip">triggers <b>'+totalTrig+'</b></span>';
887
+ if (!robots.length){ grid.innerHTML='<div class="empty" style="grid-column:1/-1">No robot telemetry yet — it populates as robots trigger sessions.</div>'; return; }
888
+ var html='';
889
+ for (var i=0;i<robots.length;i++){
890
+ var x = robots[i];
891
+ var name = robotName(x);
892
+ var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—';
893
+ var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';
894
+ var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'—';
895
+ var active = x.active_session||x.in_session||x.session_active;
896
+ var idAttr = esc(name);
897
+ html += '<div class="rp-card" data-name="'+esc(name)+'"><h4>'+esc(name)+(active?' <span class="chip" style="color:var(--ok)">live</span>':'')+'</h4><div class="rp-metrics">'
898
+ + '<div class="rp-m"><span class="v">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class="l">triggers</span></div>'
899
+ + '<div class="rp-m"><span class="v">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class="l">sessions</span></div>'
900
+ + '<div class="rp-m"><span class="v">'+lat+'</span><span class="l">avg latency</span></div>'
901
+ + '<div class="rp-m"><span class="v '+dcls+'">'+drop+'</span><span class="l">drop rate</span></div>'
902
+ + '</div><div class="rp-actions">'
903
+ + '<button class="rp-btn" data-act="trigger" data-robot="'+idAttr+'"'+(rpReadOnly?' disabled':'')+'>Trigger motion</button>'
904
+ + '<button class="rp-btn" data-act="preview" data-robot="'+idAttr+'">Preview</button>'
905
+ + '<button class="rp-btn danger" data-act="end" data-robot="'+idAttr+'"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'
906
+ + '</div>'
907
+ + '<div class="rp-field" style="margin-top:0.45rem"><label style="font-size:0.64rem">Character preset</label><select class="rp-char" data-act="char" data-robot="'+idAttr+'"><option value="">'+esc(x.character_id||x.character_preset_id||'— auto —')+'</option></select></div></div>';
908
+ }
909
+ grid.innerHTML = html;
910
+ fillCharacterDropdowns();
911
+ var sels=document.querySelectorAll('.rp-char');
912
+ for(var si=0;si<sels.length;si++){ var ss=sels[si]; var current=ss.value; ss.dataset.current=current; }
913
+ }).catch(function(){
914
+ $('rp-grid').innerHTML='<div class="empty" style="grid-column:1/-1">telemetry unreachable</div>';
915
+ });
916
+ }
917
+
918
+ // ══════════════════════════════════════════════════════════════════
919
+ // Park isometric live map of the mesh
920
+ // Real data: /fed/status nodes (placement + load) + /fed/stream frames
921
+ // (agent movement + tool-use pulses) + /robopark telemetry (session stats).
922
+ // Robot cam targets a LiveKit track via /robopark/api/robots/:id/stream.
923
+ // ══════════════════════════════════════════════════════════════════
924
+ var Park = (function(){
925
+ var C, X, DPR=1, W=0, H=0, cam={x:0,y:0,z:1}, fitted=false;
926
+ var nodes=[], byId={}, orbs=[], pulses=[], t0=0, raf=null, running=false, es=null, animSeq=-1;
927
+ var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null;
928
+ var previewOn=false, previewTimer=null; // on-demand operator camera preview
929
+ var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
930
+ var lkRoom=null, micAnalyser=null, audioCtx=null;
931
+ var imgs={}; // preloaded robot concept avatars
932
+ var TW=44, TH=22, PARK_W=15, PARK_D=8;
933
+ // The real park (from the aerial photo): named robot pads at their ground
934
+ // positions along the long Shenzhen Bay promenade plaza, plus palms, pond,
935
+ // street and stage. A mesh node binds to a pad when its display name matches
936
+ // the pad keywords (robobmw → VIXEN/BMW).
937
+ var SLOTS=[
938
+ {label:'VOLT', keys:['volt'], nx:0.18, ny:0.26, img:'/static/robopark/volt.png'},
939
+ {label:'TESLA', keys:['tesla'], nx:0.38, ny:0.30, img:'/static/robopark/tesla.png'},
940
+ {label:'JAGUAR', keys:['jaguar','jag'], nx:0.58, ny:0.34, img:'/static/robopark/jaguar.png'},
941
+ {label:'VIXEN', keys:['vixen','bmw'], nx:0.54, ny:0.66, sub:'BMW', img:'/static/robopark/bmw.png'},
942
+ {label:'PANDA', keys:['panda'], nx:0.82, ny:0.52, img:'/static/robopark/panda.png'}
943
+ ];
944
+ var HUBSLOT={label:'CONTROL', keys:[], nx:0.06, ny:0.16};
945
+ // Palm positions tuned to the aerial: clusters along the back edge and
946
+ // scattered down the center of the paved strip.
947
+ var PALMS=[
948
+ [0.10,0.38],[0.14,0.55],[0.18,0.72],[0.22,0.30],[0.26,0.48],[0.30,0.66],
949
+ [0.36,0.24],[0.40,0.42],[0.44,0.60],[0.50,0.26],[0.54,0.46],[0.58,0.64],
950
+ [0.66,0.30],[0.70,0.50],[0.74,0.68],[0.80,0.36],[0.84,0.56],[0.90,0.40],[0.94,0.62],
951
+ [0.62,0.80],[0.76,0.84],[0.35,0.82]
952
+ ];
953
+ // Pond / curved water feature at the lower-left of the plaza.
954
+ var POND={cx:0.28*15, cy:0.96*8, rx:3.4, ry:1.6};
955
+ // Street traffic along the top road (yellow taxis + private cars).
956
+ var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];
957
+
958
+ function mono(){ return 'ui-monospace,Menlo,Consolas,monospace'; }
959
+ function W2(nx,ny){ return [nx*PARK_W, ny*PARK_D]; }
960
+ function kindOf(n){
961
+ var r=(n.role||'').toLowerCase();
962
+ if(r==='hub'||r==='scheduler') return 'hub';
963
+ if(r==='satellite'||r==='robot') return 'robot';
964
+ var caps=(n.capabilities||[]).join(' ');
965
+ if(/robot|pi-client|scheduler/.test(caps) && r!=='peer') return 'robot';
966
+ return 'peer';
967
+ }
968
+ function loadColor(l){ return l>=75?getVar('--bad'):l>=45?getVar('--warn'):getVar('--ok'); }
969
+ function getVar(v){ try{ return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#ccc'; }catch(e){ return '#ccc'; } }
970
+
971
+ // Bind mesh nodes onto the fixed park pads; unmatched pads render as
972
+ // offline placeholders so the whole park is always visible.
973
+ function layout(src){
974
+ var list=(src||[]).slice(), used={}, out=[];
975
+ function prevHit(id){ for(var p=0;p<nodes.length;p++){ if(nodes[p].id===id) return nodes[p]._hit; } return null; }
976
+ function bind(slot, wx, wy, kind){
977
+ var match=null;
978
+ for(var i=0;i<list.length;i++){ var n=list[i]; if(used[n.nodeId]) continue;
979
+ var nm=(n.displayName||n.nodeId||'').toLowerCase(), isHub=kindOf(n)==='hub';
980
+ if(kind==='hub'){ if(isHub){ match=n; break; } }
981
+ 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; }
982
+ }
983
+ var id = match?match.nodeId:('pad:'+slot.label);
984
+ if(match) used[match.nodeId]=1;
985
+ return {
986
+ id:id, kind:kind, label:slot.label,
987
+ name: match?(match.displayName||match.nodeId):slot.label.toLowerCase(),
988
+ sub: match?(slot.sub||''):'not joined',
989
+ connected: match?!!match.connected:false,
990
+ load: (match&&match.load!=null)?match.load:0,
991
+ platform: match?(match.platform||''):'', version: match?(match.version||'?'):'—',
992
+ site: match?(match.site||''):'', caps: match?(match.capabilities||[]):[],
993
+ telemKeys: [slot.label.toLowerCase()].concat(slot.keys||[]).concat(match?[(match.displayName||'').toLowerCase()]:[]),
994
+ wx:wx, wy:wy, _hit:prevHit(id)
995
+ };
996
+ }
997
+ var h=W2(HUBSLOT.nx,HUBSLOT.ny); out.push(bind(HUBSLOT,h[0],h[1],'hub'));
998
+ SLOTS.forEach(function(s){ var w=W2(s.nx,s.ny); out.push(bind(s,w[0],w[1],'robot')); });
999
+ // Any extra live node that matched no pad → line it along the near edge.
1000
+ var ex=0;
1001
+ list.forEach(function(n){ if(used[n.nodeId]) return; used[n.nodeId]=1;
1002
+ var w=W2(0.20+ex*0.13, 0.90); ex++;
1003
+ out.push({ id:n.nodeId, kind:kindOf(n), label:(n.displayName||n.nodeId), name:(n.displayName||n.nodeId),
1004
+ sub:n.role||'', connected:!!n.connected, load:(n.load!=null?n.load:0), platform:n.platform||'', version:n.version||'?',
1005
+ site:n.site||'', caps:n.capabilities||[], telemKeys:[(n.displayName||'').toLowerCase()], wx:w[0], wy:w[1], _hit:prevHit(n.nodeId) });
1006
+ });
1007
+ return out;
1008
+ }
1009
+
1010
+ function onStatus(s){
1011
+ if(!s) return;
1012
+ nodes = layout(s.nodes||[]);
1013
+ byId={}; nodes.forEach(function(n){ byId[n.id]=n; });
1014
+ // if not streaming, animate from the polled activity tail
1015
+ if(!es || es.readyState!==1){ (s.activity||[]).forEach(function(e){ ingest(e.f||e, true); }); }
1016
+ }
1017
+
1018
+ function hub(){ for(var i=0;i<nodes.length;i++) if(nodes[i].kind==='hub') return nodes[i]; return nodes[0]; }
1019
+ function findByFrame(f){
1020
+ var key=(f.n==null?'':String(f.n)).toLowerCase(); if(!key) return null;
1021
+ for(var i=0;i<nodes.length;i++){ var n=nodes[i];
1022
+ if(n.id===f.n || (n.name||'').toLowerCase()===key) return n;
1023
+ for(var k=0;k<n.telemKeys.length;k++){ if(n.telemKeys[k] && key.indexOf(n.telemKeys[k])>-1) return n; }
1024
+ }
1025
+ return null;
1026
+ }
1027
+ function ingest(f, fromPoll){
1028
+ if(f.s!=null){ if(f.s<=animSeq) return; animSeq=f.s; }
1029
+ var n=findByFrame(f), h=hub();
1030
+ if(n && h){
1031
+ if(n.kind==='hub'){ pulse(n, glyphFor(f)); }
1032
+ else if(f.t==='hw'||f.t==='hb'){ travel(n,h,getVar('--ok')); }
1033
+ else { travel(h,n,getVar('--info')); pulse(n, glyphFor(f)); }
1034
+ }
1035
+ feedRow(f);
1036
+ }
1037
+ function glyphFor(f){
1038
+ if(f.t==='rt') return '⇄';
1039
+ if(f.t==='ev'){ var k=(f.d&&f.d.k||'').toLowerCase(); if(/tool|search|crawl|term|exec|code/.test(k)) return '⌨'; if(/session|presence/.test(k)) return '🎙'; return '✦'; }
1040
+ if(f.t==='hw'||f.t==='hb') return '';
1041
+ return '';
1042
+ }
1043
+ function travel(a,b,color){ if(a&&b&&a!==b) orbs.push({a:a,b:b,t:0,color:color}); }
1044
+ function pulse(n,g){ if(n) pulses.push({n:n,t:0,g:g||'✦'}); }
1045
+
1046
+ function feedRow(f){
1047
+ var rows=$('pk-rows'); if(!rows) return;
1048
+ var kind = (f.t==='ev'&&/tool|search|crawl|term|exec|code/.test((f.d&&f.d.k||'')))?'tool':f.t;
1049
+ var r=document.createElement('div'); r.className='pk-row';
1050
+ r.innerHTML='<span class="k pk-k-'+esc(kind)+'"></span><span class="t">'+clock(f.ts)+'</span>'
1051
+ +'<span class="who" title="'+esc(f.n)+'">'+esc((f.n||'').slice(0,10))+'</span>'
1052
+ +'<span class="msg">'+esc(summarizeFrame(f))+'</span>';
1053
+ rows.insertBefore(r, rows.firstChild);
1054
+ while(rows.childNodes.length>44) rows.removeChild(rows.lastChild);
1055
+ }
1056
+
1057
+ // ── canvas + projection ──
1058
+ function ensure(){
1059
+ if(C) return;
1060
+ C=$('pk-canvas'); X=C.getContext('2d'); t0=performance.now();
1061
+ DPR=Math.max(1,Math.min(2,window.devicePixelRatio||1));
1062
+ var d=false,px,py;
1063
+ C.addEventListener('mousedown',function(e){d=true;px=e.clientX;py=e.clientY;});
1064
+ window.addEventListener('mouseup',function(){d=false;});
1065
+ window.addEventListener('mousemove',function(e){ if(!d)return; cam.x+=e.clientX-px; cam.y+=e.clientY-py; px=e.clientX; py=e.clientY; });
1066
+ 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});
1067
+ C.addEventListener('click',function(e){
1068
+ var r=C.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top, hit=null;
1069
+ 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]; }
1070
+ if(hit){ openDrawer(hit.id); if(String(hit.id).indexOf('pad:')!==0) selectNode(hit.id, hit.name); }
1071
+ });
1072
+ window.addEventListener('resize', function(){ if(running) resize(); });
1073
+ }
1074
+ function resize(){
1075
+ var st=$('pk-stage'); if(!st) return;
1076
+ W=st.clientWidth; H=st.clientHeight; C.width=W*DPR; C.height=H*DPR; X.setTransform(DPR,0,0,DPR,0,0);
1077
+ if(!fitted){ fit(); fitted=true; }
1078
+ }
1079
+ function fit(){
1080
+ var span=PARK_W+PARK_D;
1081
+ var zx=(W-330)/(span*TW*0.5+120), zy=H/(span*TH+220);
1082
+ cam.z=Math.max(0.5,Math.min(1.5, Math.min(zx,zy))); cam.x=0; cam.y=0;
1083
+ }
1084
+ // World (park) coords → screen. Origin biased left so the right-docked feed
1085
+ // doesn't cover the plaza; the park is centered on (PARK_W/2, PARK_D/2).
1086
+ function iso(wx,wy){
1087
+ var dx=(wx-PARK_W/2)-(wy-PARK_D/2), dy=(wx-PARK_W/2)+(wy-PARK_D/2);
1088
+ return { x:(W-300)/2 + cam.x + dx*TW*cam.z, y:H*0.42 + cam.y + dy*TH*cam.z };
1089
+ }
1090
+ function poly(pts, fill, stroke, lw){
1091
+ X.beginPath();
1092
+ 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); }
1093
+ X.closePath();
1094
+ if(fill){ X.fillStyle=fill; X.fill(); }
1095
+ if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }
1096
+ }
1097
+ function drawGround(){
1098
+ // grass base
1099
+ 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)');
1100
+ 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);
1101
+ // street band along the far edge + dashed centerline + cars
1102
+ 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);
1103
+ X.strokeStyle='rgba(232,210,120,0.5)'; X.setLineDash([9*cam.z,9*cam.z]);
1104
+ 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([]);
1105
+ CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });
1106
+ // white event tent / stage on the far left of the plaza (matches aerial)
1107
+ drawTent(0.85, 0.18);
1108
+ // paved plaza — long rectangular promenade matching the aerial footprint
1109
+ var px0=0.2,py0=-0.10,px1=PARK_W-0.2,py1=PARK_D-0.45;
1110
+ 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)');
1111
+ poly([[px0,py0],[px1,py0],[px1,py1],[px0,py1]], pg, 'rgba(255,255,255,0.07)',1);
1112
+ // plaza grid (tile joints)
1113
+ X.strokeStyle='rgba(255,255,255,0.05)'; X.lineWidth=1;
1114
+ for(var gx=Math.ceil(px0);gx<px1;gx+=1.5){ 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(); }
1115
+ for(var gy=Math.ceil(py0);gy<py1;gy+=1.5){ 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(); }
1116
+ // curved pond / water feature at bottom-left
1117
+ var pts=[]; for(var t=0;t<Math.PI*2;t+=Math.PI/12){ pts.push([POND.cx+POND.rx*Math.cos(t), POND.cy+POND.ry*Math.sin(t)*0.7]); }
1118
+ 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)');
1119
+ poly(pts, wg, 'rgba(130,205,235,0.32)',1.5);
1120
+ // winding footpath along the pond
1121
+ X.strokeStyle='rgba(210,190,150,0.35)'; X.lineWidth=2.5*cam.z; X.setLineDash([]);
1122
+ var pathPts=[[0.15*PARK_W,0.82*PARK_D],[0.22*PARK_W,0.88*PARK_D],[0.32*PARK_W,0.92*PARK_D],[0.45*PARK_W,0.95*PARK_D]];
1123
+ X.beginPath();
1124
+ for(var pi=0;pi<pathPts.length;pi++){ var pp=iso(pathPts[pi][0],pathPts[pi][1]); if(pi===0)X.moveTo(pp.x,pp.y); else X.lineTo(pp.x,pp.y); }
1125
+ X.stroke();
1126
+ }
1127
+ function drawTent(wx,wy){
1128
+ var p=iso(wx,wy), z=cam.z;
1129
+ // canopy shadow
1130
+ X.fillStyle='rgba(0,0,0,0.28)'; X.beginPath(); X.ellipse(p.x,p.y+4*z,22*z,8*z,0,0,7); X.fill();
1131
+ // white peaked tent
1132
+ X.fillStyle='rgba(235,235,240,0.92)'; X.strokeStyle='rgba(180,180,190,0.6)'; X.lineWidth=1;
1133
+ X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x,p.y-26*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();
1134
+ X.beginPath(); X.moveTo(p.x-20*z,p.y); X.lineTo(p.x-12*z,p.y+16*z); X.lineTo(p.x+12*z,p.y+16*z); X.lineTo(p.x+20*z,p.y); X.closePath(); X.fill(); X.stroke();
1135
+ // support poles
1136
+ X.strokeStyle='rgba(160,160,170,0.7)'; X.lineWidth=1.5*z;
1137
+ X.beginPath(); X.moveTo(p.x-10*z,p.y+16*z); X.lineTo(p.x-10*z,p.y+5*z); X.stroke();
1138
+ X.beginPath(); X.moveTo(p.x+10*z,p.y+16*z); X.lineTo(p.x+10*z,p.y+5*z); X.stroke();
1139
+ }
1140
+ function drawCar(wx,wy,col){
1141
+ var p=iso(wx,wy), z=cam.z;
1142
+ 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();
1143
+ X.fillStyle=col; X.beginPath(); X.roundRect(p.x-11*z,p.y-7*z,22*z,12*z,3*z); X.fill();
1144
+ 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();
1145
+ }
1146
+ function drawPalm(wx,wy){
1147
+ var p=iso(wx,wy), z=cam.z;
1148
+ 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();
1149
+ 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();
1150
+ var tx=p.x+1*z, ty=p.y-24*z; X.strokeStyle='#3f8f4a'; X.lineWidth=2.4*z;
1151
+ 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(); }
1152
+ X.fillStyle='#caa23a'; X.beginPath(); X.arc(tx,ty+1*z,2*z,0,7); X.fill();
1153
+ }
1154
+ function ring(cx,cy,r,frac,color){
1155
+ 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();
1156
+ 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();
1157
+ }
1158
+ function label(cx,cy,txt,col,sub){
1159
+ var z=cam.z; X.textAlign='center';
1160
+ X.font='700 '+(11*z)+'px '+mono(); X.fillStyle=col; X.fillText(txt,cx,cy);
1161
+ X.font=(8.5*z)+'px '+mono(); X.fillStyle='rgba(200,185,150,0.9)'; X.fillText(sub,cx,cy+11*z);
1162
+ }
1163
+ 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(); }
1164
+ function drawHub(n){
1165
+ 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;
1166
+ 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();
1167
+ X.fillStyle='rgba(40,34,58,0.92)'; X.strokeStyle=getVar('--gold-bright'); X.lineWidth=1.2;
1168
+ X.beginPath(); X.rect(bx-16*z,by-40*z,32*z,44*z); X.fill(); X.stroke();
1169
+ 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; }
1170
+ X.strokeStyle=getVar('--gold'); X.beginPath(); X.moveTo(bx,by-40*z); X.lineTo(bx,by-52*z); X.stroke();
1171
+ X.fillStyle=getVar('--gold-bright'); X.beginPath(); X.arc(bx,by-53*z,3*z,0,7); X.fill();
1172
+ label(bx,p.y+16*z,n.name,getVar('--gold-bright'), (n.connected?'HUB · '+Math.round(n.load)+'%':'offline'));
1173
+ n._hit={x:bx-20*z,y:by-54*z,w:40*z,h:80*z};
1174
+ }
1175
+ function drawRobot(n){
1176
+ 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;
1177
+ 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);
1178
+ 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();
1179
+ var col=n.connected?getVar('--gold-bright'):'rgba(150,143,120,0.5)';
1180
+ X.globalAlpha=n.connected?1:0.7;
1181
+ var img = n.connected && imgs[n.label];
1182
+ if(img && img.complete && img.naturalWidth>0){
1183
+ var iw=26*z, ih=26*z*img.naturalHeight/img.naturalWidth;
1184
+ X.save();
1185
+ X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.clip();
1186
+ X.drawImage(img, bx-13*z, by-44*z, iw, ih+18*z);
1187
+ X.restore();
1188
+ X.strokeStyle=col; X.lineWidth=1.6; X.beginPath(); X.roundRect(bx-13*z,by-38*z,iw,ih+12*z,6*z); X.stroke();
1189
+ } else {
1190
+ X.fillStyle='rgba(24,20,38,0.92)'; X.strokeStyle=col; X.lineWidth=1.6;
1191
+ X.beginPath(); X.roundRect(bx-11*z,by-24*z,22*z,25*z,5*z); X.fill(); X.stroke();
1192
+ 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();
1193
+ 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();
1194
+ X.strokeStyle=col; X.beginPath(); X.moveTo(bx,by-37*z); X.lineTo(bx,by-44*z); X.stroke();
1195
+ X.fillStyle=n.connected?'#FF5B4A':'#655'; X.beginPath(); X.arc(bx,by-45*z,2*z,0,7); X.fill();
1196
+ }
1197
+ X.globalAlpha=1;
1198
+ var sub=n.connected?((n.sub&&n.sub!==n.name?n.sub+' · ':'')+Math.round(n.load)+'%'):'not joined';
1199
+ label(bx,p.y+17*z,n.label,col,sub);
1200
+ n._hit={x:bx-15*z,y:by-47*z,w:30*z,h:60*z};
1201
+ }
1202
+ function drawPeer(n){
1203
+ var p=iso(n.wx,n.wy), z=cam.z, bx=p.x, by=p.y-3*z;
1204
+ 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();
1205
+ var col=n.connected?getVar('--info'):'rgba(150,143,120,0.5)';
1206
+ X.fillStyle='rgba(24,22,40,0.9)'; X.strokeStyle=col; X.lineWidth=1.4;
1207
+ X.beginPath(); X.roundRect(bx-13*z,by-22*z,26*z,22*z,4*z); X.fill(); X.stroke();
1208
+ 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;
1209
+ label(bx,p.y+15*z,n.label,col,(n.connected?n.sub:'offline'));
1210
+ n._hit={x:bx-15*z,y:by-24*z,w:30*z,h:42*z};
1211
+ }
1212
+
1213
+ function frame(){
1214
+ if(!running) return;
1215
+ X.clearRect(0,0,W,H);
1216
+ drawGround();
1217
+ // depth-sort palms + nodes so nearer objects overlap farther ones
1218
+ var objs=[];
1219
+ 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}); }
1220
+ for(var ni=0;ni<nodes.length;ni++){ objs.push({wx:nodes[ni].wx,wy:nodes[ni].wy,n:nodes[ni]}); }
1221
+ objs.sort(function(a,b){ return (a.wx+a.wy)-(b.wx+b.wy); });
1222
+ 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); } });
1223
+ 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;}
1224
+ 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;
1225
+ 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;
1226
+ 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;
1227
+ }
1228
+ 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;}
1229
+ var pp=iso(pu.n.wx,pu.n.wy), yy=pp.y-56*cam.z-pu.t*24*cam.z;
1230
+ 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);
1231
+ 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;
1232
+ }
1233
+ raf=requestAnimationFrame(frame);
1234
+ }
1235
+
1236
+ function connectStream(){
1237
+ if(es) return;
1238
+ try{
1239
+ es=new EventSource('/fed/stream'+QS);
1240
+ es.onmessage=function(ev){ try{ var f=JSON.parse(ev.data); ingest(f,false); }catch(e){} };
1241
+ es.onopen=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/stream · live'; };
1242
+ es.onerror=function(){ var s=$('pk-src'); if(s) s.textContent='/fed/status · poll'; };
1243
+ }catch(e){ es=null; }
1244
+ }
1245
+
1246
+ var sdkLoading=false;
1247
+ function loadSDK(){
1248
+ if(window.LivekitClient || sdkLoading) return; sdkLoading=true;
1249
+ var s=document.createElement('script'); s.src='/vendor/livekit-client.umd.js'+QS; s.async=true;
1250
+ s.onerror=function(){ sdkLoading=false; };
1251
+ // The SDK is ~500KB — a camera view or "Go live" click can easily fire
1252
+ // before it finishes loading. Without this, that first attempt sees
1253
+ // window.LivekitClient===undefined, falls into the permanent "no-sdk"
1254
+ // idle state, and never retries even once the SDK is ready.
1255
+ s.onload=function(){
1256
+ sdkLoading=false;
1257
+ if(!drawerId) return;
1258
+ var n=byId[drawerId];
1259
+ if(n && n.kind==='robot'){ startCam(n); if(previewOn) startPreview(n); }
1260
+ };
1261
+ document.head.appendChild(s);
1262
+ }
1263
+ function loadImages(){
1264
+ SLOTS.forEach(function(s){ if(!s.img) return; var i=new Image(); i.src=s.img+QS; i.onload=function(){ imgs[s.label]=i; }; i.onerror=function(){ imgs[s.label]=null; }; });
1265
+ }
1266
+ function activate(){
1267
+ ensure();
1268
+ loadSDK();
1269
+ loadImages();
1270
+ if(lastStatus) onStatus(lastStatus);
1271
+ resize();
1272
+ connectStream();
1273
+ if(!running){ running=true; raf=requestAnimationFrame(frame); }
1274
+ }
1275
+ function deactivate(){ running=false; if(raf) cancelAnimationFrame(raf); raf=null; }
1276
+
1277
+ // ── robot drawer ──
1278
+ function sect(title, inner){ return '<div class="pk-sect"><h4>'+title+'</h4>'+inner+'</div>'; }
1279
+ function openDrawer(id){
1280
+ var n=byId[id]; if(!n) return; drawerId=id;
1281
+ $('pk-drawer').classList.add('open');
1282
+ $('pk-av').textContent = n.kind==='hub'?'🖥':(n.kind==='robot'?'🤖':'🧩');
1283
+ $('pk-name').textContent=n.label||n.name;
1284
+ $('pk-sub').textContent = n.connected ? (n.name+' · '+(n.platform||'?')+' · v'+n.version+(n.site?(' · '+n.site):'')) : 'pad reserved · not joined';
1285
+ var st=$('pk-state'); st.textContent=n.connected?'live':'offline'; st.className='pk-pill '+(n.connected?'live':'idle');
1286
+ buildDrawer(n);
1287
+ pollRoboparkQuiet(); // fill session telemetry right away instead of waiting for the tick
1288
+ if(drawerTimer) clearInterval(drawerTimer);
1289
+ drawerTimer=setInterval(function(){ if(activeTab==='park'&&drawerId){ pollRoboparkQuiet(); } }, 3000);
1290
+ }
1291
+ // Resolve a mesh node's *scheduler* robot id n.name is the mesh display
1292
+ // name, which is not necessarily the scheduler's robots.id (e.g. an
1293
+ // enrolled device's id is a generated dev_xxxx, distinct from its name).
1294
+ // Falls back to n.name if telemetry hasn't matched a row yet.
1295
+ function schedRow(n){
1296
+ var row=roboparkByName[n.name];
1297
+ 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; } }
1298
+ return row;
1299
+ }
1300
+ function schedId(n){ var row=schedRow(n); return (row&&(row.id||row.robot_id))||n.name; }
1301
+ function closeDrawer(){
1302
+ // tell the scheduler to end any live preview so the robot stops its cam
1303
+ if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }
1304
+ previewOn=false; if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
1305
+ stopVision();
1306
+ $('pk-drawer').classList.remove('open'); drawerId=null;
1307
+ if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;
1308
+ if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
1309
+ if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
1310
+ if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
1311
+ if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
1312
+ micAnalyser=null;
1313
+ }
1314
+ // ── on-demand operator preview: open the robot's cam without a scene-detection session ──
1315
+ function updateGoLive(){ var b=$('pk-golive'); if(b){ b.textContent = previewOn?'■ Stop live':'▶ Go live'; b.classList.toggle('danger', previewOn); } }
1316
+ function goLive(n){ if(previewOn) stopPreview(n); else startPreview(n); }
1317
+ function startPreview(n){
1318
+ previewOn=true; updateGoLive();
1319
+ var note=$('pk-golivenote'); if(note) note.textContent='requesting preview…';
1320
+ if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; } // drop any session cam first
1321
+ idleCam('connecting');
1322
+ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/start'+QS, {method:'POST', cache:'no-store'})
1323
+ .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
1324
+ .then(function(info){
1325
+ if(!previewOn) return; // stopped while awaiting
1326
+ if(info && info.active===false){ idleCam(info.reason||'idle'); if(note) note.textContent=(info.reason==='production_mode_off'?'production mode is off':'preview unavailable'); previewOn=false; updateGoLive(); return; }
1327
+ if(info && info.url && info.token && window.LivekitClient){ attachLiveKit(info, n); if(note) note.textContent='live preview · robot opening camera…'; }
1328
+ else { idleCam('no-sdk'); }
1329
+ if(previewTimer) clearInterval(previewTimer);
1330
+ previewTimer=setInterval(function(){ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/keepalive'+QS, {method:'POST'}).catch(function(){}); }, 20000);
1331
+ })
1332
+ .catch(function(){ idleCam('unreachable'); if(note) note.textContent='scheduler unreachable'; previewOn=false; updateGoLive(); });
1333
+ }
1334
+ function stopPreview(n){
1335
+ previewOn=false; updateGoLive();
1336
+ if(previewTimer){ clearInterval(previewTimer); previewTimer=null; }
1337
+ if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
1338
+ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/preview/stop'+QS, {method:'POST'}).catch(function(){});
1339
+ var note=$('pk-golivenote'); if(note) note.textContent='';
1340
+ idleCam('idle');
1341
+ }
1342
+ // RoboVisionAI_PI serves its own MJPEG stream directly (not via LiveKit),
1343
+ // so the browser hits the robot's LAN IP straight on, no scheduler proxy.
1344
+ // The scheduler learns that IP from the device's own heartbeats.
1345
+ function toggleVision(n){
1346
+ var img=$('pk-vision-img'), note=$('pk-vision-note'), btn=$('pk-vision-toggle');
1347
+ if(visionOn){
1348
+ visionOn=false; if(img){ img.src=''; img.style.display='none'; }
1349
+ if(btn) btn.textContent='🔍 Show overlay';
1350
+ if(note) note.textContent="object/motion detection overlay from the robot's vision server (RoboVisionAI_PI)";
1351
+ return;
1352
+ }
1353
+ var row=schedRow(n);
1354
+ var ip=row&&row.lan_ip;
1355
+ if(!ip){ if(note) note.textContent='no known IP for this robot yet — waiting for a heartbeat with lan_ip'; return; }
1356
+ var port=(row&&row.vision_port)||5000;
1357
+ visionOn=true;
1358
+ if(btn) btn.textContent=' Hide overlay';
1359
+ if(note) note.textContent='streaming from http://'+ip+':'+port+'/video_feed';
1360
+ if(img){ img.src='http://'+ip+':'+port+'/video_feed'; img.style.display='block'; }
1361
+ }
1362
+ function stopVision(){
1363
+ visionOn=false;
1364
+ var img=$('pk-vision-img'); if(img){ img.src=''; img.style.display='none'; }
1365
+ }
1366
+ function pollRoboparkQuiet(){
1367
+ fetch('/robopark/api/telemetry'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){
1368
+ var list=(Array.isArray(d)?d:(d&&d.robots)||[]); roboparkByName={};
1369
+ list.forEach(function(x){ roboparkByName[x.robot_id||x.id||x.name||'robot']=x; if(x.name) roboparkByName[x.name]=x; });
1370
+ refreshDrawer();
1371
+ }).catch(function(){});
1372
+ }
1373
+ function buildDrawer(n){
1374
+ var b=$('pk-body');
1375
+ if(n.kind==='robot' && !n.connected){
1376
+ b.innerHTML =
1377
+ sect('🅿 park pad · '+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>')
1378
+ + sect('📍 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>');
1379
+ return;
1380
+ }
1381
+ if(n.kind==='robot'){
1382
+ b.innerHTML =
1383
+ sect('👁 camera feed',
1384
+ '<div class="pk-cam" id="pk-cam"><canvas id="pk-camcv" width="384" height="216"></canvas>'
1385
+ +'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
1386
+ +'<div class="pk-note" id="pk-camnote">seeking LiveKit track…</div>'
1387
+ +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>')
1388
+ + sect('🔍 vision overlay',
1389
+ '<div class="pk-cam" id="pk-vision-wrap"><img id="pk-vision-img" style="width:100%;display:none;border-radius:inherit" alt="vision feed"/></div>'
1390
+ +'<div class="pk-note" id="pk-vision-note">object/motion detection overlay from the robot vision server (RoboVisionAI_PI)</div>'
1391
+ +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-vision-toggle">🔍 Show overlay</button></div>')
1392
+ + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
1393
+ + sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">awaiting robot audio plugin</div>')
1394
+ + sect('📊 hardware','<div class="pk-hwgrid"><div><div class="l">load</div><div class="track"><div class="fill" id="pk-hw-load"></div></div></div><div><div class="l">status</div><div class="track"><div class="fill" id="pk-hw-conn" style="background:'+(n.connected?'var(--ok)':'var(--muted)')+'"></div></div></div></div><div class="pk-note">remote node — full CPU/mem telemetry shows on the node itself</div>')
1395
+ + sect('🗓 session telemetry','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-trig">—</div><div class="l">triggers</div></div><div class="pk-kv"><div class="v" id="pk-tv-sess">—</div><div class="l">sessions</div></div><div class="pk-kv"><div class="v" id="pk-tv-lat">—</div><div class="l">avg latency</div></div><div class="pk-kv"><div class="v" id="pk-tv-drop">—</div><div class="l">drop rate</div></div></div>')
1396
+ + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn danger" id="pk-end" data-robot="'+esc(n.name)+'">End session</button><button class="rp-btn" id="pk-follow">Follow run</button></div><div class="pk-note">end-session is live via the scheduler; cam/mic/speaker fill in as the robot plugin publishes them</div>');
1397
+ startCam(n); startMeters(n);
1398
+ var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
1399
+ var vt=$('pk-vision-toggle'); if(vt) vt.addEventListener('click', function(){ toggleVision(n); });
1400
+ var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
1401
+ 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); });
1402
+ } else {
1403
+ b.innerHTML =
1404
+ sect('🖥 node','<div class="pk-grid2"><div class="pk-kv"><div class="v" id="pk-tv-load">—</div><div class="l">load</div></div><div class="pk-kv"><div class="v '+(n.connected?'ok':'')+'">'+(n.connected?'up':'down')+'</div><div class="l">status</div></div></div>')
1405
+ + sect('🔌 capabilities', (n.caps.length?('<div class="caps">'+n.caps.map(function(c){return '<span class="cap">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class="pk-note">none reported</div>'))
1406
+ + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn" id="pk-status">Run /status</button></div>');
1407
+ var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });
1408
+ }
1409
+ refreshDrawer();
1410
+ }
1411
+ function refreshDrawer(){
1412
+ var n=drawerId?byId[drawerId]:null; if(!n) return;
1413
+ setFill('pk-hw-load', n.load); setFill('pk-tv-load', null);
1414
+ var lv=$('pk-tv-load'); if(lv) lv.textContent=Math.round(n.load)+'%';
1415
+ var hc=$('pk-hw-conn'); if(hc) hc.style.width=n.connected?'100%':'12%';
1416
+ var row=schedRow(n);
1417
+ if(row){
1418
+ set('pk-tv-trig', row.trigger_count!=null?row.trigger_count:0);
1419
+ set('pk-tv-sess', row.sessions_total!=null?row.sessions_total:0);
1420
+ set('pk-tv-lat', row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'—');
1421
+ var dEl=$('pk-tv-drop'); if(dEl){ var d=row.drop_rate!=null?Math.round(row.drop_rate*100):null; dEl.textContent=d!=null?d+'%':'—'; dEl.className='v '+(d>=20?'bad':d>=5?'warn':'ok'); }
1422
+ var active=row.active_session||row.in_session||row.session_active;
1423
+ 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')); }
1424
+ }
1425
+ }
1426
+ function set(id,v){ var e=$(id); if(e) e.textContent=v; }
1427
+ 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)'; }
1428
+
1429
+ // ── cam: target a LiveKit track; fall back to a styled preview ──
1430
+ function startCam(n){
1431
+ var note=$('pk-camnote');
1432
+ camAbort = ('AbortController' in window)?new AbortController():null;
1433
+ idleCam('connecting');
1434
+ // scheduler mints a subscribe-only viewer token for the robot's current
1435
+ // LiveKit room; the Pi already publishes 'cam' (video) + 'mic' (audio).
1436
+ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS, {cache:'no-store', signal:camAbort?camAbort.signal:undefined})
1437
+ .then(function(r){ return r.json().catch(function(){ throw new Error('bad response'); }); })
1438
+ .then(function(info){
1439
+ if(info && info.active===false){ idleCam(info.reason||'idle'); return; }
1440
+ if(info && info.url && info.token){
1441
+ if(window.LivekitClient){ attachLiveKit(info, n); }
1442
+ else { idleCam('no-sdk'); }
1443
+ } else { idleCam('idle'); }
1444
+ })
1445
+ .catch(function(){ idleCam('unreachable'); });
1446
+ }
1447
+ function idleCam(reason){
1448
+ var cc=$('pk-camcv'), msg={connecting:'connecting to live session…', idle:'robot idle · no live session', production_mode_off:'production mode off', 'no-sdk':'live session up · loading video…', unreachable:'scheduler unreachable'}[reason]||'no live feed';
1449
+ 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); }
1450
+ var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'—';
1451
+ var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;
1452
+ }
1453
+ function attachLiveKit(info, n){
1454
+ var note=$('pk-camnote'), res=$('pk-camres');
1455
+ try{
1456
+ var LK=window.LivekitClient; lkRoom=new LK.Room();
1457
+ lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){
1458
+ if(track.kind==='video'){
1459
+ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
1460
+ var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);
1461
+ if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit · '+esc(info.room);
1462
+ } else if(track.kind==='audio'){ hookMicAnalyser(track); track.attach(); }
1463
+ });
1464
+ lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='—'; });
1465
+ lkRoom.connect(info.url, info.token).then(function(){ if(note) note.textContent='connected · waiting for camera track…'; })
1466
+ .catch(function(){ idleCam('unreachable'); });
1467
+ }catch(e){ idleCam('no-sdk'); }
1468
+ }
1469
+ // Real mic level: tap the robot's subscribed 'mic' audio track with a Web
1470
+ // Audio analyser and read RMS in startMeters (no invented levels).
1471
+ function hookMicAnalyser(track){
1472
+ try{
1473
+ var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
1474
+ audioCtx=audioCtx||new AC();
1475
+ var ms=track.mediaStreamTrack; if(!ms) return;
1476
+ var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));
1477
+ var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;
1478
+ }catch(e){}
1479
+ }
1480
+ function previewCam(n){
1481
+ var cc=$('pk-camcv'); if(!cc||!cc.getContext) return; var cx=cc.getContext('2d'), w=cc.width, h=cc.height, s0=performance.now();
1482
+ var res=$('pk-camres'); if(res && res.textContent==='— · —') res.textContent='preview · 24fps';
1483
+ (function loop(){ if(!$('pk-camcv')||drawerId==null) return;
1484
+ var tt=(performance.now()-s0)/1000;
1485
+ 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);
1486
+ 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();
1487
+ var px=w*0.5+Math.sin(tt*0.6)*w*0.28, py=h*0.6;
1488
+ cx.fillStyle='#e9c07a'; cx.beginPath(); cx.arc(px,py-34,10,0,7); cx.fill();
1489
+ cx.fillStyle='#c98f3a'; cx.fillRect(px-9,py-24,18,26);
1490
+ cx.strokeStyle='rgba(111,224,166,0.9)'; cx.lineWidth=1.5; cx.strokeRect(px-16,py-48,32,52);
1491
+ cx.fillStyle='#6FE0A6'; cx.font='9px monospace'; cx.fillText('person 0.9'+Math.floor(Math.abs(Math.sin(tt))*9), px-15, py-52);
1492
+ 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;
1493
+ 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); }
1494
+ var ts=$('pk-camts'); if(ts) ts.textContent=clock(Date.now());
1495
+ camRAF=requestAnimationFrame(loop);
1496
+ })();
1497
+ }
1498
+ 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'); }
1499
+ function startMeters(n){
1500
+ var mb=$('pk-micbars'), sb=$('pk-spkbars'); if(!mb) return;
1501
+ var mi=bars(mb,18), si=bars(sb,18), sw=$('pk-spkwave'), swx=sw?sw.getContext('2d'):null, buf=new Uint8Array(128);
1502
+ (function loop(){ if(!$('pk-micbars')||drawerId==null) return;
1503
+ // MIC — real RMS from the subscribed LiveKit mic track (else no signal).
1504
+ var lvl=0;
1505
+ 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); }
1506
+ 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)'; }
1507
+ set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'—');
1508
+ set('pk-micstate', micAnalyser?(lvl>0.12?'live · hearing audio':'live · quiet'):'no live mic track');
1509
+ // SPEAKER — the Pi plays TTS out its own speaker; it is not published as a
1510
+ // track, so report status honestly instead of inventing a level.
1511
+ for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }
1512
+ set('pk-spkval','—'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');
1513
+ 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(); }
1514
+ meterRAF=requestAnimationFrame(loop);
1515
+ })();
1516
+ }
1517
+
1518
+ function openDrawerForRobot(name){
1519
+ var key=String(name||'').toLowerCase(), id='pad:'+name, found=null;
1520
+ for(var i=0;i<nodes.length;i++){ var n=nodes[i]; if(n.kind==='robot' && ((n.name||'').toLowerCase()===key || n.telemKeys.some(function(t){ return t===key; }))){ found=n; break; } }
1521
+ var node=found || { id:id, kind:'robot', name:name, telemKeys:[key], connected:true, label:name };
1522
+ if(!byId[id]){ byId[id]=node; nodes.push(node); }
1523
+ openDrawer(node.id);
1524
+ }
1525
+
1526
+ $('pk-close') && $('pk-close').addEventListener('click', closeDrawer);
1527
+ return { onStatus:onStatus, activate:activate, deactivate:deactivate, refreshDrawer:refreshDrawer, openDrawerForRobot:openDrawerForRobot };
1528
+ })();
1529
+
1530
+ poll();
1531
+ setInterval(poll, 2000);
1532
+ setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);
1533
+ initTab();
1534
+ })();
1535
+ </script>
1536
+ </body>
1428
1537
  </html>`;