infinicode 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -1
- package/dist/commands/robopark.js +9 -0
- package/dist/commands/serve.d.ts +4 -0
- package/dist/commands/serve.js +19 -1
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +190 -0
- package/dist/kernel/federation/federation.d.ts +17 -0
- package/dist/kernel/federation/federation.js +8 -0
- package/dist/kernel/federation/transport-http.d.ts +17 -0
- package/dist/kernel/federation/transport-http.js +52 -2
- package/dist/robopark-cli.js +1 -1
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -19,7 +19,7 @@ import { meshInstall, meshUninstall } from './commands/mesh-install.js';
|
|
|
19
19
|
import { installTui } from './commands/install-tui.js';
|
|
20
20
|
import { attachRoboparkSubcommands } from './commands/robopark.js';
|
|
21
21
|
import { DEFAULT_CONFIG } from './kernel/config-schema.js';
|
|
22
|
-
const VERSION = '2.
|
|
22
|
+
const VERSION = '2.5.1';
|
|
23
23
|
const config = new Conf({
|
|
24
24
|
projectName: 'infinicode',
|
|
25
25
|
defaults: DEFAULT_CONFIG,
|
|
@@ -296,6 +296,10 @@ program
|
|
|
296
296
|
.option('--tag <tag>', 'only discover peers carrying this tag (Tailscale ACL tag or LAN beacon tag)')
|
|
297
297
|
.option('--console', 'interactive slash-command console on the node (/nodes, /build, /activity …); on by default in a TTY')
|
|
298
298
|
.option('--dashboard', 'serve the RoboPark control web UI at http://<host>:<port>/ (fleet map, live activity, actions)')
|
|
299
|
+
.option('--scheduler-url <url>', 'link a RoboPark scheduler so the dashboard shows sessions + telemetry (e.g. http://localhost:8080)')
|
|
300
|
+
.option('--scheduler-token <token>', 'bearer token to present to the scheduler on proxied requests')
|
|
301
|
+
.option('--tls-cert <path>', 'PEM cert file — serve the dashboard/mesh over HTTPS')
|
|
302
|
+
.option('--tls-key <path>', 'PEM private key file for --tls-cert')
|
|
299
303
|
.option('--gateway <url>', 'link to an InfiniBot gateway (ws://host:18789) — appear on its neural-mesh map')
|
|
300
304
|
.option('--gateway-token <token>', 'auth token for the InfiniBot gateway')
|
|
301
305
|
.option('--gateway-password <password>', 'auth password for the InfiniBot gateway')
|
|
@@ -193,6 +193,10 @@ async function roboparkSetup(config, opts) {
|
|
|
193
193
|
const meshRole = preset.mesh;
|
|
194
194
|
// Preset defaults, overridable by explicit flags.
|
|
195
195
|
const dashboard = opts.dashboard ?? preset.dashboard;
|
|
196
|
+
// The RoboPark scheduler API runs on the same box as the on-site hub, so the
|
|
197
|
+
// scheduler role links it by default → the dashboard's Sessions & Telemetry
|
|
198
|
+
// tab is live out of the box. Other roles don't proxy a scheduler.
|
|
199
|
+
const schedulerUrl = opts.schedulerUrl ?? (label === 'scheduler' ? 'http://127.0.0.1:8080' : undefined);
|
|
196
200
|
const lan = opts.lan ?? preset.lan;
|
|
197
201
|
const tailscale = opts.tailscale ?? (preset.tailscale && !opts.hub); // seed URL replaces discovery
|
|
198
202
|
const port = opts.port ? parseInt(opts.port, 10) : DEFAULT_PORT;
|
|
@@ -241,6 +245,8 @@ async function roboparkSetup(config, opts) {
|
|
|
241
245
|
lan,
|
|
242
246
|
tag: opts.tag,
|
|
243
247
|
dashboard,
|
|
248
|
+
schedulerUrl,
|
|
249
|
+
schedulerToken: opts.schedulerToken,
|
|
244
250
|
gateway: opts.gateway,
|
|
245
251
|
gatewayToken: opts.gatewayToken,
|
|
246
252
|
};
|
|
@@ -253,6 +259,7 @@ async function roboparkSetup(config, opts) {
|
|
|
253
259
|
'infinicode serve',
|
|
254
260
|
`--role ${meshRole}`,
|
|
255
261
|
dashboard ? '--dashboard' : '',
|
|
262
|
+
schedulerUrl ? `--scheduler-url ${schedulerUrl}` : '',
|
|
256
263
|
lan ? '--lan' : '',
|
|
257
264
|
tailscale ? '--tailscale' : '',
|
|
258
265
|
opts.tag ? `--tag ${opts.tag}` : '',
|
|
@@ -406,6 +413,8 @@ export function attachRoboparkSubcommands(cmd, config) {
|
|
|
406
413
|
.option('--lan', 'force zero-config LAN auto-discovery (on by default for scheduler/robot)')
|
|
407
414
|
.option('--tailscale', 'force Tailscale peer discovery (on by default for scheduler/laptop)')
|
|
408
415
|
.option('--dashboard', 'force the web control UI (on by default for scheduler)')
|
|
416
|
+
.option('--scheduler-url <url>', 'RoboPark scheduler API to link for the dashboard Sessions tab (default http://127.0.0.1:8080 for the scheduler role)')
|
|
417
|
+
.option('--scheduler-token <token>', 'bearer token presented to the scheduler on proxied requests')
|
|
409
418
|
.option('--start', 'launch the node now instead of just writing config')
|
|
410
419
|
.option('--yes', 'skip the guided wizard even with no role (non-interactive)')
|
|
411
420
|
.action(async (opts) => {
|
package/dist/commands/serve.d.ts
CHANGED
|
@@ -18,5 +18,9 @@ export interface ServeOptions {
|
|
|
18
18
|
gatewayToken?: string;
|
|
19
19
|
gatewayPassword?: string;
|
|
20
20
|
dashboard?: boolean;
|
|
21
|
+
schedulerUrl?: string;
|
|
22
|
+
schedulerToken?: string;
|
|
23
|
+
tlsCert?: string;
|
|
24
|
+
tlsKey?: string;
|
|
21
25
|
}
|
|
22
26
|
export declare function serve(config: Conf<InfinicodeConfig>, opts: ServeOptions): Promise<void>;
|
package/dist/commands/serve.js
CHANGED
|
@@ -78,6 +78,17 @@ export async function serve(config, opts) {
|
|
|
78
78
|
reason: 'this node is network-exposed without a token — only read-only commands are allowed. Restart with --token <secret> to enable control commands.',
|
|
79
79
|
};
|
|
80
80
|
};
|
|
81
|
+
// Optional TLS material for an HTTPS dashboard/mesh.
|
|
82
|
+
let tls;
|
|
83
|
+
if (opts.tlsCert && opts.tlsKey) {
|
|
84
|
+
try {
|
|
85
|
+
tls = { cert: readFileSync(opts.tlsCert), key: readFileSync(opts.tlsKey) };
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.log(chalk.red(` ✗ could not read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
81
92
|
const kernel = buildKernelFromConfig(config);
|
|
82
93
|
const identity = loadOrCreateIdentity({ role, displayName: opts.name ?? fed.displayName });
|
|
83
94
|
// Manifest inputs are read live from the kernel so the map shows real caps.
|
|
@@ -145,6 +156,10 @@ export async function serve(config, opts) {
|
|
|
145
156
|
dashboard: opts.dashboard,
|
|
146
157
|
onApply,
|
|
147
158
|
commandGate,
|
|
159
|
+
tls,
|
|
160
|
+
schedulerUrl: opts.schedulerUrl ?? fed.schedulerUrl,
|
|
161
|
+
schedulerToken: opts.schedulerToken,
|
|
162
|
+
schedulerReadOnly: !trusted,
|
|
148
163
|
},
|
|
149
164
|
});
|
|
150
165
|
// Let the kernel's slash-command surface reach the mesh (/nodes, /node, …).
|
|
@@ -167,8 +182,11 @@ export async function serve(config, opts) {
|
|
|
167
182
|
// Control dashboard link — prefer a reachable host over the 0.0.0.0 wildcard.
|
|
168
183
|
if (opts.dashboard) {
|
|
169
184
|
const uiHost = isLoopback(bindHost) || bindHost === '0.0.0.0' ? 'localhost' : bindHost;
|
|
170
|
-
const
|
|
185
|
+
const scheme = tls ? 'https' : 'http';
|
|
186
|
+
const url = `${scheme}://${uiHost}:${port}/${token ? `?token=${token}` : ''}`;
|
|
171
187
|
console.log(chalk.bold(` dashboard: ${chalk.cyan(url)}`));
|
|
188
|
+
if (opts.schedulerUrl)
|
|
189
|
+
console.log(chalk.dim(` scheduler linked: ${opts.schedulerUrl} (sessions + telemetry tabs live)`));
|
|
172
190
|
if (!token)
|
|
173
191
|
console.log(chalk.dim(' (no token — anyone who can reach this port can control the fleet; add --token to lock it)'));
|
|
174
192
|
console.log();
|
|
@@ -10,4 +10,4 @@
|
|
|
10
10
|
* Kept free of backticks and ${...} so the whole page fits inside this TS
|
|
11
11
|
* template literal without escaping; the client JS uses string concatenation.
|
|
12
12
|
*/
|
|
13
|
-
export declare const DASHBOARD_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>RoboPark Control</title>\n<style>\n:root{\n --bg:#FFF8DE; --panel:#FBF1CE; --panel-2:#F3E4B4; --border:#E0C982; --border-strong:#B97816;\n --ink:#231704; --muted:#6E4A12; --gold:#7A420A; --gold-bright:#B97816;\n --ok:#2F7A32; --ok-bg:#E4F0DD; --warn:#9A6A12; --warn-bg:#F6E9C4; --bad:#B5382E; --bad-bg:#F6DED9;\n --info:#1F6F9E; --info-bg:#DCEEF7;\n --mono:ui-monospace,\"Cascadia Code\",Consolas,monospace;\n --sans:system-ui,\"Segoe UI\",Roboto,sans-serif;\n}\n@media (prefers-color-scheme:dark){:root{\n --bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;\n --ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;\n --info:#6FB6FF; --info-bg:#0F2231;\n}}\n:root[data-theme=\"dark\"]{\n --bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;\n --ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;\n --info:#6FB6FF; --info-bg:#0F2231;\n}\n:root[data-theme=\"light\"]{\n --bg:#FFF8DE; --panel:#FBF1CE; --panel-2:#F3E4B4; --border:#E0C982; --border-strong:#B97816;\n --ink:#231704; --muted:#6E4A12; --gold:#7A420A; --gold-bright:#B97816;\n --ok:#2F7A32; --ok-bg:#E4F0DD; --warn:#9A6A12; --warn-bg:#F6E9C4; --bad:#B5382E; --bad-bg:#F6DED9;\n --info:#1F6F9E; --info-bg:#DCEEF7;\n}\n*{box-sizing:border-box;margin:0;padding:0}\nhtml,body{height:100%}\nbody{background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}\n.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}\n\n/* top bar */\nheader.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;\n padding:0.7rem 1.1rem;border-bottom:1px solid var(--border);background:var(--panel);position:sticky;top:0;z-index:5;}\n.brand{display:flex;align-items:center;gap:0.6rem;}\n.livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 70%,transparent);animation:pulse 2.4s infinite;flex:none;}\n.livedot.stale{background:var(--muted);animation:none;}\n@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}70%{box-shadow:0 0 0 7px transparent}100%{box-shadow:0 0 0 0 transparent}}\n@media (prefers-reduced-motion:reduce){.livedot{animation:none}}\n.brand b{font-size:0.98rem;letter-spacing:-0.01em;}\n.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}\n.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--border);\n background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}\n.chip b{color:var(--ink);font-weight:600;}\n.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}\n.themebtn{font-family:var(--mono);font-size:0.68rem;background:transparent;border:1px solid var(--border);color:var(--muted);\n border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}\n.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n\n/* layout */\n.wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}\n@media (max-width:920px){.wrap{grid-template-columns:1fr}}\nsection h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}\nsection h2 .count{color:var(--muted);}\n\n/* fleet cards */\n.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}\n.node{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--muted);border-radius:9px;\n padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s;}\n.node:hover{transform:translateY(-1px);}\n.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.72;}\n.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}\n.node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}\n.node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}\n.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}\n.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);\n border-radius:4px;padding:0 0.3em;}\n.bar-track{height:6px;border-radius:4px;background:var(--panel-2);overflow:hidden;margin-bottom:0.3rem;}\n.bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;}\n.bar-fill.warn{background:var(--warn)} .bar-fill.bad{background:var(--bad)}\n.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}\n.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}\n.cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border-radius:4px;padding:0.05em 0.35em;}\n.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:9px;}\n\n/* hardware meters */\n.hw{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:0.85rem;margin-top:1rem;}\n.meter{margin-bottom:0.65rem;}\n.meter:last-child{margin-bottom:0;}\n.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}\n.meter .lab b{color:var(--ink);}\n\n/* activity feed */\naside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}\n.feed{background:var(--panel);border:1px solid var(--border);border-radius:9px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;}\n.line{padding:0.28rem 0.6rem;border-bottom:1px solid color-mix(in srgb,var(--border) 55%,transparent);display:flex;gap:0.5rem;line-height:1.3;}\n.line:last-child{border-bottom:none;}\n.line .t{color:var(--muted);flex:none;}\n.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.line .d{color:var(--ink);flex:1;word-break:break-word;}\n.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}\n\n/* command bar */\nfooter.cmd{position:fixed;left:0;right:0;bottom:0;background:var(--panel);border-top:1px solid var(--border-strong);\n padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;z-index:6;}\nfooter .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}\nfooter .sel b{color:var(--ink);}\nfooter input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:var(--bg);color:var(--ink);\n border:1px solid var(--border);border-radius:7px;padding:0.5em 0.7em;}\nfooter input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\nbutton.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:7px;padding:0.5em 0.85em;\n border:1px solid var(--border-strong);background:var(--gold-bright);color:#231704;white-space:nowrap;}\nbutton.act.ghost{background:transparent;color:var(--gold);}\nbutton.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}\nbutton.act:disabled{opacity:0.5;cursor:default;}\n#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;background:var(--panel-2);\n border:1px solid var(--border-strong);border-radius:9px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;\n white-space:pre-wrap;display:none;box-shadow:0 8px 30px rgba(0,0,0,0.25);z-index:7;}\n#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}\n.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;}\n</style>\n</head>\n<body>\n<div class=\"offline\" id=\"offline\">connection lost \u2014 retrying</div>\n<header class=\"bar\">\n <div class=\"brand\">\n <span class=\"livedot\" id=\"livedot\"></span>\n <div>\n <b>RoboPark Control</b>\n <div class=\"who mono\" id=\"who\">connecting\u2026</div>\n </div>\n </div>\n <div class=\"chips\" id=\"summary\"></div>\n <button class=\"themebtn\" id=\"theme\">Theme</button>\n</header>\n\n<div class=\"wrap\">\n <section>\n <h2>Fleet <span class=\"count\" id=\"fleetcount\"></span></h2>\n <div class=\"fleet\" id=\"fleet\"></div>\n <div class=\"hw\" id=\"hw\"></div>\n </section>\n <aside>\n <h2>Live activity</h2>\n <div class=\"feed\" id=\"feed\"></div>\n </aside>\n</div>\n\n<footer class=\"cmd\">\n <span class=\"sel\">on <b id=\"selName\">this node</b></span>\n <input id=\"cmdInput\" placeholder=\"/status /nodes /health (Enter to run)\" autocomplete=\"off\" spellcheck=\"false\">\n <button class=\"act\" id=\"runBtn\">Run</button>\n <button class=\"act ghost\" id=\"applyBtn\">Apply config to fleet</button>\n</footer>\n<div id=\"out\"><button class=\"x\" id=\"outClose\">close</button><span id=\"outText\"></span></div>\n\n<script>\n(function(){\n var TOKEN = new URLSearchParams(location.search).get('token') || '';\n var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n\n var $ = function(id){ return document.getElementById(id); };\n function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }\n function fmtBytes(b){ if(!b) return '0'; var u=['B','KB','MB','GB','TB']; var i=Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i?1:0)+u[i]; }\n function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }\n function clock(ts){ var d=new Date(ts); function p(n){return (n<10?'0':'')+n;} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }\n\n // theme\n (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}\n if(s) document.documentElement.setAttribute('data-theme', s);\n $('theme').addEventListener('click', function(){\n var m=window.matchMedia('(prefers-color-scheme: dark)');\n var cur=document.documentElement.getAttribute('data-theme')||(m.matches?'dark':'light');\n var nx=cur==='dark'?'light':'dark';\n document.documentElement.setAttribute('data-theme', nx);\n try{localStorage.setItem(k,nx)}catch(e){}\n });\n })();\n\n // Pull a short human detail out of an event's data payload.\n function detailOf(data){\n var p = data || {};\n if(typeof p==='string') return p;\n if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' \u2713':' \u2717'):'');\n if(p.model) return String(p.model);\n if(p.provider) return String(p.provider);\n if(p.reason) return String(p.reason).slice(0,60);\n if(p.description) return String(p.description).slice(0,50);\n if(p.name) return String(p.name);\n return '';\n }\n function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }\n\n function summarizeFrame(f){\n var d = f.d || {};\n if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% \u00B7 mem '+m+'%'; }\n if(f.t==='hb'){ return 'heartbeat \u00B7 load '+(d.load!=null?d.load:'-'); }\n if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }\n // routing decision: compact payload is {k, m, task, w, d}\n if(f.t==='rt'){ var rp=d.d||{}; return 'route \u2192 '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }\n // immediate lifecycle event: {k:type, m, task, w, d:payload}\n if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }\n // coalesced chatter: {count, tail:[{k, d}]}\n if(f.t==='log'){\n if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);\n return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' \u00D7'+d.count):''); }\n return 'log';\n }\n return f.t;\n }\n\n function renderFeed(activity){\n var feed = $('feed');\n if(!activity || !activity.length){\n if(!feed.dataset.seeded){ feed.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">waiting for fleet activity \u2014 task runs, routing, and peer events stream here.</span></div>'; }\n return;\n }\n feed.dataset.seeded = '1';\n var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;\n var html = '';\n for(var i=0;i<activity.length;i++){\n var e = activity[i]; var f = e.f || e; if(f.s!=null && f.s<=lastSeq && f.t!=='hw') { /* keep */ }\n var name = (f.n||'').slice(0,10);\n html += '<div class=\"line k-'+esc(f.t)+'\"><span class=\"t\">'+clock(f.ts)+'</span>'\n + '<span class=\"n\" title=\"'+esc(f.n)+'\">'+esc(name)+'</span>'\n + '<span class=\"d\">'+esc(summarizeFrame(f))+'</span></div>';\n }\n feed.innerHTML = html;\n if(near) feed.scrollTop = feed.scrollHeight;\n }\n\n function meter(label, value, sub){\n var v = Math.max(0, Math.min(100, Math.round(value||0)));\n return '<div class=\"meter\"><div class=\"lab\"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'\n + (sub?(' '+esc(sub)):'')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div></div>';\n }\n\n function renderHw(hw){\n var el = $('hw');\n if(!hw){ el.innerHTML = '<div class=\"meter\"><div class=\"lab\"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }\n var html = '<h2 style=\"margin-top:0\">This node \u00B7 '+esc(hw.system?hw.system.hostname:'')+'</h2>';\n html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');\n html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');\n if(hw.gpu){ html += meter('GPU '+esc(hw.gpu.name||hw.gpu.vendor||''), hw.gpu.utilizationPercent, hw.gpu.vramTotalBytes?(fmtBytes(hw.gpu.vramUsedBytes)+' / '+fmtBytes(hw.gpu.vramTotalBytes)+' vram'):''); }\n el.innerHTML = html;\n }\n\n function selectNode(id, name){\n selected = (id===selfId)?null:id;\n $('selName').textContent = selected ? name : 'this node';\n var cards = document.querySelectorAll('.node');\n for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);\n }\n\n function renderFleet(nodes){\n var el = $('fleet');\n if(!nodes || !nodes.length){ el.innerHTML = '<div class=\"empty\">No nodes reporting yet. Start peers with <span class=\"mono\">infinicode serve</span> or <span class=\"mono\">robopark setup</span>.</div>'; return; }\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var html = '';\n for(var i=0;i<nodes.length;i++){\n var n = nodes[i];\n var isSelf = n.nodeId===selfId;\n var load = n.load!=null?n.load:0;\n var caps = (n.capabilities||[]).slice(0,6);\n var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class=\"cap\">'+esc(caps[c])+'</span>';\n var site = n.site?('\u00B7 '+esc(n.site)):'';\n html += '<div class=\"node '+(n.connected?'up':'down')+(selected===n.nodeId?' sel':'')+'\" tabindex=\"0\" role=\"button\" data-id=\"'+esc(n.nodeId)+'\" data-name=\"'+esc(n.displayName)+'\">'\n + '<div class=\"top\"><span class=\"name\">'+esc(n.displayName)+(isSelf?' <span class=\"selfbadge\">this</span>':'')+'</span><span class=\"role\">'+esc(n.role)+'</span></div>'\n + '<div class=\"meta\">'+esc(n.platform||'')+' '+site+' <span class=\"mono\">v'+esc(n.version||'?')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(load)+'\" style=\"width:'+(n.connected?load:0)+'%\"></div></div>'\n + '<div class=\"load\"><span>'+(n.connected?'load '+Math.round(load)+'%':'offline')+'</span><span>'+((n.commands||[]).length)+' cmds</span></div>'\n + (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'')\n + '</div>';\n }\n el.innerHTML = html;\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }\n card.addEventListener('click', pick);\n card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });\n })(cards[j]);\n }\n }\n\n function renderSummary(s){\n var nodes = s.nodes||[];\n var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;\n var down = nodes.length-up;\n var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;\n var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});\n var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;\n $('summary').innerHTML =\n '<span class=\"chip ok\">nodes up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">active runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>';\n $('fleetcount').textContent = '(' + nodes.length + ')';\n var self = s.self||{};\n var role = s.role||{};\n $('who').textContent = (self.displayName||'node') + ' \u00B7 ' + (self.role||'?') + (role.site?(' \u00B7 '+role.site):'') + ' \u00B7 v' + (self.version||'?');\n }\n\n function setLive(ok){\n $('livedot').classList.toggle('stale', !ok);\n $('offline').style.display = (!ok && everOk) ? 'block' : 'none';\n }\n\n function poll(){\n fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){\n if(!r.ok) throw new Error('status '+r.status);\n return r.json();\n }).then(function(s){\n everOk = true; setLive(true);\n if(!selfId && s.self) selfId = s.self.nodeId;\n renderSummary(s);\n renderFleet(s.nodes||[]);\n renderHw(s.hardware);\n renderFeed(s.activity||[]);\n }).catch(function(){ setLive(false); });\n }\n\n // command execution\n function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }\n $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });\n\n function runCommand(input){\n input = (input||'').trim();\n if(!input) return;\n // Target a peer by prefixing the kernel's node-scoped command form.\n var payload = selected ? ('/node ' + selected + ' ' + input) : input;\n $('runBtn').disabled = true;\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })\n .catch(function(e){ showOut('request failed: '+e.message); })\n .then(function(){ $('runBtn').disabled=false; });\n }\n\n $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });\n $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });\n $('applyBtn').addEventListener('click', function(){\n $('applyBtn').disabled = true;\n fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })\n .catch(function(e){ showOut('apply failed: '+e.message); })\n .then(function(){ $('applyBtn').disabled=false; });\n });\n\n poll();\n setInterval(poll, 2000);\n})();\n</script>\n</body>\n</html>";
|
|
13
|
+
export declare const DASHBOARD_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>RoboPark Control</title>\n<style>\n:root{\n --bg:#FFF8DE; --panel:#FBF1CE; --panel-2:#F3E4B4; --border:#E0C982; --border-strong:#B97816;\n --ink:#231704; --muted:#6E4A12; --gold:#7A420A; --gold-bright:#B97816;\n --ok:#2F7A32; --ok-bg:#E4F0DD; --warn:#9A6A12; --warn-bg:#F6E9C4; --bad:#B5382E; --bad-bg:#F6DED9;\n --info:#1F6F9E; --info-bg:#DCEEF7;\n --mono:ui-monospace,\"Cascadia Code\",Consolas,monospace;\n --sans:system-ui,\"Segoe UI\",Roboto,sans-serif;\n}\n@media (prefers-color-scheme:dark){:root{\n --bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;\n --ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;\n --info:#6FB6FF; --info-bg:#0F2231;\n}}\n:root[data-theme=\"dark\"]{\n --bg:#0C0904; --panel:#161009; --panel-2:#1F1710; --border:#3A2A0C; --border-strong:#7A420A;\n --ink:#F3E6C4; --muted:#A98A4E; --gold:#E6B23C; --gold-bright:#FFD86B;\n --ok:#7FCE7A; --ok-bg:#15230F; --warn:#E0A23C; --warn-bg:#241A0A; --bad:#FF7A6E; --bad-bg:#2A100C;\n --info:#6FB6FF; --info-bg:#0F2231;\n}\n:root[data-theme=\"light\"]{\n --bg:#FFF8DE; --panel:#FBF1CE; --panel-2:#F3E4B4; --border:#E0C982; --border-strong:#B97816;\n --ink:#231704; --muted:#6E4A12; --gold:#7A420A; --gold-bright:#B97816;\n --ok:#2F7A32; --ok-bg:#E4F0DD; --warn:#9A6A12; --warn-bg:#F6E9C4; --bad:#B5382E; --bad-bg:#F6DED9;\n --info:#1F6F9E; --info-bg:#DCEEF7;\n}\n*{box-sizing:border-box;margin:0;padding:0}\nhtml,body{height:100%}\nbody{background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}\n.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;}\n\n/* top bar */\nheader.bar{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;\n padding:0.7rem 1.1rem;border-bottom:1px solid var(--border);background:var(--panel);position:sticky;top:0;z-index:5;}\n.brand{display:flex;align-items:center;gap:0.6rem;}\n.livedot{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 70%,transparent);animation:pulse 2.4s infinite;flex:none;}\n.livedot.stale{background:var(--muted);animation:none;}\n@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}70%{box-shadow:0 0 0 7px transparent}100%{box-shadow:0 0 0 0 transparent}}\n@media (prefers-reduced-motion:reduce){.livedot{animation:none}}\n.brand b{font-size:0.98rem;letter-spacing:-0.01em;}\n.brand .who{font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.chips{display:flex;gap:0.45rem;flex-wrap:wrap;align-items:center;}\n.chip{font-family:var(--mono);font-size:0.68rem;padding:0.28em 0.62em;border-radius:100px;border:1px solid var(--border);\n background:var(--panel-2);color:var(--muted);display:inline-flex;gap:0.4em;align-items:center;white-space:nowrap;}\n.chip b{color:var(--ink);font-weight:600;}\n.chip.ok b{color:var(--ok)} .chip.bad b{color:var(--bad)} .chip.info b{color:var(--info)}\n.themebtn{font-family:var(--mono);font-size:0.68rem;background:transparent;border:1px solid var(--border);color:var(--muted);\n border-radius:100px;padding:0.34em 0.8em;cursor:pointer;}\n.themebtn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n\n/* layout */\n.wrap{display:grid;grid-template-columns:1fr 340px;gap:1rem;padding:1rem 1.1rem 6.5rem;max-width:1400px;margin:0 auto;}\n@media (max-width:920px){.wrap{grid-template-columns:1fr}}\nsection h2,aside h2{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-bright);\n margin:0.2rem 0 0.7rem;display:flex;align-items:center;gap:0.5rem;}\nsection h2 .count{color:var(--muted);}\n\n/* fleet cards */\n.fleet{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem;}\n.node{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--muted);border-radius:9px;\n padding:0.75rem 0.85rem;cursor:pointer;position:relative;transition:border-color .12s,transform .12s;}\n.node:hover{transform:translateY(-1px);}\n.node.up{border-left-color:var(--ok);} .node.down{border-left-color:var(--muted);opacity:0.72;}\n.node.sel{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.node .top{display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;margin-bottom:0.15rem;}\n.node .name{font-family:var(--mono);font-size:0.9rem;font-weight:600;word-break:break-word;}\n.node .role{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--gold);}\n.node .meta{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;}\n.node .selfbadge{font-family:var(--mono);font-size:0.58rem;color:var(--info);border:1px solid color-mix(in srgb,var(--info) 40%,transparent);\n border-radius:4px;padding:0 0.3em;}\n.bar-track{height:6px;border-radius:4px;background:var(--panel-2);overflow:hidden;margin-bottom:0.3rem;}\n.bar-fill{height:100%;background:var(--ok);width:0;transition:width .4s ease;}\n.bar-fill.warn{background:var(--warn)} .bar-fill.bad{background:var(--bad)}\n.node .load{font-family:var(--mono);font-size:0.64rem;color:var(--muted);display:flex;justify-content:space-between;}\n.caps{display:flex;gap:0.25rem;flex-wrap:wrap;margin-top:0.5rem;}\n.cap{font-family:var(--mono);font-size:0.58rem;color:var(--muted);background:var(--panel-2);border-radius:4px;padding:0.05em 0.35em;}\n.empty{color:var(--muted);font-size:0.82rem;padding:1.4rem;text-align:center;border:1px dashed var(--border);border-radius:9px;}\n\n/* hardware meters */\n.hw{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:0.85rem;margin-top:1rem;}\n.meter{margin-bottom:0.65rem;}\n.meter:last-child{margin-bottom:0;}\n.meter .lab{display:flex;justify-content:space-between;font-family:var(--mono);font-size:0.68rem;color:var(--muted);margin-bottom:0.25rem;}\n.meter .lab b{color:var(--ink);}\n\n/* activity feed */\naside{position:sticky;top:64px;align-self:start;max-height:calc(100vh - 84px);display:flex;flex-direction:column;}\n.feed{background:var(--panel);border:1px solid var(--border);border-radius:9px;overflow-y:auto;flex:1;min-height:240px;font-family:var(--mono);font-size:0.7rem;}\n.line{padding:0.28rem 0.6rem;border-bottom:1px solid color-mix(in srgb,var(--border) 55%,transparent);display:flex;gap:0.5rem;line-height:1.3;}\n.line:last-child{border-bottom:none;}\n.line .t{color:var(--muted);flex:none;}\n.line .n{color:var(--gold);flex:none;max-width:9ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.line .d{color:var(--ink);flex:1;word-break:break-word;}\n.line.k-ev .d{color:var(--info);} .line.k-hw .n{color:var(--muted);} .line.k-rt .d{color:var(--gold-bright);}\n\n/* command bar */\nfooter.cmd{position:fixed;left:0;right:0;bottom:0;background:var(--panel);border-top:1px solid var(--border-strong);\n padding:0.6rem 1.1rem;display:flex;gap:0.55rem;align-items:center;flex-wrap:wrap;z-index:6;}\nfooter .sel{font-family:var(--mono);font-size:0.72rem;color:var(--muted);white-space:nowrap;}\nfooter .sel b{color:var(--ink);}\nfooter input{flex:1;min-width:180px;font-family:var(--mono);font-size:0.8rem;background:var(--bg);color:var(--ink);\n border:1px solid var(--border);border-radius:7px;padding:0.5em 0.7em;}\nfooter input:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\nbutton.act{font-family:var(--sans);font-size:0.76rem;font-weight:600;cursor:pointer;border-radius:7px;padding:0.5em 0.85em;\n border:1px solid var(--border-strong);background:var(--gold-bright);color:#231704;white-space:nowrap;}\nbutton.act.ghost{background:transparent;color:var(--gold);}\nbutton.act:focus-visible{outline:2px solid var(--ink);outline-offset:1px;}\nbutton.act:disabled{opacity:0.5;cursor:default;}\n#out{position:fixed;right:1.1rem;bottom:3.6rem;max-width:min(560px,90vw);max-height:44vh;overflow:auto;background:var(--panel-2);\n border:1px solid var(--border-strong);border-radius:9px;padding:0.7rem 0.85rem;font-family:var(--mono);font-size:0.72rem;\n white-space:pre-wrap;display:none;box-shadow:0 8px 30px rgba(0,0,0,0.25);z-index:7;}\n#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}\n.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;}\n\n/* tabs + product panes */\n.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--border);background:var(--panel);position:sticky;top:53px;z-index:4;}\n.tab{font-family:var(--mono);font-size:0.74rem;padding:0.6rem 0.95rem;cursor:pointer;color:var(--muted);background:none;border:none;border-bottom:2px solid transparent;}\n.tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);}\n.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}\n.pane{display:none;} .pane.active{display:block;}\n.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}\n.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}\n.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}\n.rp-card{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--gold-bright);border-radius:9px;padding:0.75rem 0.85rem;}\n.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}\n.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}\n.rp-m{display:flex;flex-direction:column;}\n.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}\n.rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}\n.rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}\n.rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}\n.pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}\n.pm .state{font-weight:700;letter-spacing:0.03em;}\n.pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}\n.toggle{position:relative;width:42px;height:22px;border-radius:22px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;padding:0;transition:background .15s;}\n.toggle::after{content:\"\";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}\n.toggle[aria-checked=\"true\"]{background:var(--ok-bg);border-color:var(--ok);}\n.toggle[aria-checked=\"true\"]::after{transform:translateX(20px);background:var(--ok);}\n.toggle:disabled{opacity:0.45;cursor:default;}\n.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}\n.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}\n.rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:6px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}\n.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}\n.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}\n.rp-btn:disabled{opacity:0.4;cursor:default;}\n.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}\n.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}\n</style>\n</head>\n<body>\n<div class=\"offline\" id=\"offline\">connection lost \u2014 retrying</div>\n<header class=\"bar\">\n <div class=\"brand\">\n <span class=\"livedot\" id=\"livedot\"></span>\n <div>\n <b>RoboPark Control</b>\n <div class=\"who mono\" id=\"who\">connecting\u2026</div>\n </div>\n </div>\n <div class=\"chips\" id=\"summary\"></div>\n <button class=\"themebtn\" id=\"theme\">Theme</button>\n</header>\n\n<div class=\"tabs\">\n <button class=\"tab active\" data-tab=\"fleet\" id=\"tabbtn-fleet\">Fleet</button>\n <button class=\"tab\" data-tab=\"robopark\" id=\"tabbtn-robopark\">Sessions & Telemetry</button>\n</div>\n\n<div class=\"pane active\" id=\"pane-fleet\">\n<div class=\"wrap\">\n <section>\n <h2>Fleet <span class=\"count\" id=\"fleetcount\"></span></h2>\n <div class=\"fleet\" id=\"fleet\"></div>\n <div class=\"hw\" id=\"hw\"></div>\n </section>\n <aside>\n <h2>Live activity</h2>\n <div class=\"feed\" id=\"feed\"></div>\n </aside>\n</div>\n</div>\n\n<div class=\"pane\" id=\"pane-robopark\">\n <div class=\"rp-head\">\n <h2 style=\"margin:0\">RoboPark \u00B7 sessions & telemetry</h2>\n <div class=\"rp-controls\">\n <span class=\"pm\" id=\"pm-wrap\" hidden>\n <span>production mode</span>\n <button class=\"toggle\" id=\"pm-toggle\" role=\"switch\" aria-checked=\"false\" title=\"master enable \u2014 robots only join sessions when ON\"></button>\n <span class=\"state off\" id=\"pm-state\">off</span>\n </span>\n <div class=\"rp-totals\" id=\"rp-totals\"></div>\n </div>\n </div>\n <div class=\"rp-grid\" id=\"rp-grid\"></div>\n</div>\n\n<footer class=\"cmd\">\n <span class=\"sel\">on <b id=\"selName\">this node</b></span>\n <input id=\"cmdInput\" placeholder=\"/status /nodes /health (Enter to run)\" autocomplete=\"off\" spellcheck=\"false\">\n <button class=\"act\" id=\"runBtn\">Run</button>\n <button class=\"act ghost\" id=\"applyBtn\">Apply config to fleet</button>\n</footer>\n<div id=\"out\"><button class=\"x\" id=\"outClose\">close</button><span id=\"outText\"></span></div>\n\n<script>\n(function(){\n var TOKEN = new URLSearchParams(location.search).get('token') || '';\n var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';\n var selfId = null, selected = null, lastSeq = -1, everOk = false;\n var rpReadOnly = false, pmBusy = false;\n\n var $ = function(id){ return document.getElementById(id); };\n function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }\n function fmtBytes(b){ if(!b) return '0'; var u=['B','KB','MB','GB','TB']; var i=Math.floor(Math.log(b)/Math.log(1024)); return (b/Math.pow(1024,i)).toFixed(i?1:0)+u[i]; }\n function loadClass(v){ return v>=80?'bad':(v>=50?'warn':''); }\n function clock(ts){ var d=new Date(ts); function p(n){return (n<10?'0':'')+n;} return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds()); }\n\n // theme\n (function(){ var k='rp-ctl-theme', s=null; try{s=localStorage.getItem(k)}catch(e){}\n if(s) document.documentElement.setAttribute('data-theme', s);\n $('theme').addEventListener('click', function(){\n var m=window.matchMedia('(prefers-color-scheme: dark)');\n var cur=document.documentElement.getAttribute('data-theme')||(m.matches?'dark':'light');\n var nx=cur==='dark'?'light':'dark';\n document.documentElement.setAttribute('data-theme', nx);\n try{localStorage.setItem(k,nx)}catch(e){}\n });\n })();\n\n // Pull a short human detail out of an event's data payload.\n function detailOf(data){\n var p = data || {};\n if(typeof p==='string') return p;\n if(p.providerId!=null) return p.providerId + (('healthy' in p)?(p.healthy?' \u2713':' \u2717'):'');\n if(p.model) return String(p.model);\n if(p.provider) return String(p.provider);\n if(p.reason) return String(p.reason).slice(0,60);\n if(p.description) return String(p.description).slice(0,50);\n if(p.name) return String(p.name);\n return '';\n }\n function niceKind(k){ return String(k||'').toLowerCase().replace(/_/g,' '); }\n\n function summarizeFrame(f){\n var d = f.d || {};\n if(f.t==='hw'){ var c=d.cpu?Math.round(d.cpu.percent):0; var m=d.memory?Math.round(d.memory.percent):0; return 'cpu '+c+'% \u00B7 mem '+m+'%'; }\n if(f.t==='hb'){ return 'heartbeat \u00B7 load '+(d.load!=null?d.load:'-'); }\n if(f.t==='nd'){ return 'fleet update ('+(Array.isArray(d)?d.length:0)+' nodes)'; }\n // routing decision: compact payload is {k, m, task, w, d}\n if(f.t==='rt'){ var rp=d.d||{}; return 'route \u2192 '+(rp.model||rp.provider||rp.decision||detailOf(rp)||'?'); }\n // immediate lifecycle event: {k:type, m, task, w, d:payload}\n if(f.t==='ev'){ var det=detailOf(d.d); return niceKind(d.k)+(det?(' '+det):''); }\n // coalesced chatter: {count, tail:[{k, d}]}\n if(f.t==='log'){\n if(d.tail && d.tail.length){ var last=d.tail[d.tail.length-1]; var ld=detailOf(last.d);\n return niceKind(last.k)+(ld?(' '+ld):'')+(d.count>1?(' \u00D7'+d.count):''); }\n return 'log';\n }\n return f.t;\n }\n\n function renderFeed(activity){\n var feed = $('feed');\n if(!activity || !activity.length){\n if(!feed.dataset.seeded){ feed.innerHTML = '<div class=\"line\"><span class=\"d\" style=\"color:var(--muted)\">waiting for fleet activity \u2014 task runs, routing, and peer events stream here.</span></div>'; }\n return;\n }\n feed.dataset.seeded = '1';\n var near = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;\n var html = '';\n for(var i=0;i<activity.length;i++){\n var e = activity[i]; var f = e.f || e; if(f.s!=null && f.s<=lastSeq && f.t!=='hw') { /* keep */ }\n var name = (f.n||'').slice(0,10);\n html += '<div class=\"line k-'+esc(f.t)+'\"><span class=\"t\">'+clock(f.ts)+'</span>'\n + '<span class=\"n\" title=\"'+esc(f.n)+'\">'+esc(name)+'</span>'\n + '<span class=\"d\">'+esc(summarizeFrame(f))+'</span></div>';\n }\n feed.innerHTML = html;\n if(near) feed.scrollTop = feed.scrollHeight;\n }\n\n function meter(label, value, sub){\n var v = Math.max(0, Math.min(100, Math.round(value||0)));\n return '<div class=\"meter\"><div class=\"lab\"><span>'+esc(label)+'</span><span><b>'+v+'%</b>'\n + (sub?(' '+esc(sub)):'')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(v)+'\" style=\"width:'+v+'%\"></div></div></div>';\n }\n\n function renderHw(hw){\n var el = $('hw');\n if(!hw){ el.innerHTML = '<div class=\"meter\"><div class=\"lab\"><span>This node</span><span>no telemetry yet</span></div></div>'; return; }\n var html = '<h2 style=\"margin-top:0\">This node \u00B7 '+esc(hw.system?hw.system.hostname:'')+'</h2>';\n html += meter('CPU', hw.cpu?hw.cpu.percent:0, hw.cpu?(hw.cpu.cores+' cores'):'');\n html += meter('Memory', hw.memory?hw.memory.percent:0, hw.memory?(fmtBytes(hw.memory.usedBytes)+' / '+fmtBytes(hw.memory.totalBytes)):'');\n if(hw.gpu){ html += meter('GPU '+esc(hw.gpu.name||hw.gpu.vendor||''), hw.gpu.utilizationPercent, hw.gpu.vramTotalBytes?(fmtBytes(hw.gpu.vramUsedBytes)+' / '+fmtBytes(hw.gpu.vramTotalBytes)+' vram'):''); }\n el.innerHTML = html;\n }\n\n function selectNode(id, name){\n selected = (id===selfId)?null:id;\n $('selName').textContent = selected ? name : 'this node';\n var cards = document.querySelectorAll('.node');\n for(var i=0;i<cards.length;i++) cards[i].classList.toggle('sel', cards[i].getAttribute('data-id')===id);\n }\n\n function renderFleet(nodes){\n var el = $('fleet');\n if(!nodes || !nodes.length){ el.innerHTML = '<div class=\"empty\">No nodes reporting yet. Start peers with <span class=\"mono\">infinicode serve</span> or <span class=\"mono\">robopark setup</span>.</div>'; return; }\n nodes.sort(function(a,b){ return (b.connected?1:0)-(a.connected?1:0); });\n var html = '';\n for(var i=0;i<nodes.length;i++){\n var n = nodes[i];\n var isSelf = n.nodeId===selfId;\n var load = n.load!=null?n.load:0;\n var caps = (n.capabilities||[]).slice(0,6);\n var capHtml=''; for(var c=0;c<caps.length;c++) capHtml += '<span class=\"cap\">'+esc(caps[c])+'</span>';\n var site = n.site?('\u00B7 '+esc(n.site)):'';\n html += '<div class=\"node '+(n.connected?'up':'down')+(selected===n.nodeId?' sel':'')+'\" tabindex=\"0\" role=\"button\" data-id=\"'+esc(n.nodeId)+'\" data-name=\"'+esc(n.displayName)+'\">'\n + '<div class=\"top\"><span class=\"name\">'+esc(n.displayName)+(isSelf?' <span class=\"selfbadge\">this</span>':'')+'</span><span class=\"role\">'+esc(n.role)+'</span></div>'\n + '<div class=\"meta\">'+esc(n.platform||'')+' '+site+' <span class=\"mono\">v'+esc(n.version||'?')+'</span></div>'\n + '<div class=\"bar-track\"><div class=\"bar-fill '+loadClass(load)+'\" style=\"width:'+(n.connected?load:0)+'%\"></div></div>'\n + '<div class=\"load\"><span>'+(n.connected?'load '+Math.round(load)+'%':'offline')+'</span><span>'+((n.commands||[]).length)+' cmds</span></div>'\n + (capHtml?('<div class=\"caps\">'+capHtml+'</div>'):'')\n + '</div>';\n }\n el.innerHTML = html;\n var cards = el.querySelectorAll('.node');\n for(var j=0;j<cards.length;j++){\n (function(card){\n function pick(){ selectNode(card.getAttribute('data-id'), card.getAttribute('data-name')); }\n card.addEventListener('click', pick);\n card.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ ev.preventDefault(); pick(); } });\n })(cards[j]);\n }\n }\n\n function renderSummary(s){\n var nodes = s.nodes||[];\n var up=0; for(var i=0;i<nodes.length;i++) if(nodes[i].connected) up++;\n var down = nodes.length-up;\n var runs = (s.runs||[]).filter(function(r){return r.status==='running';}).length;\n var loads = nodes.filter(function(n){return n.connected&&n.load!=null;}).map(function(n){return n.load;});\n var avg = loads.length?Math.round(loads.reduce(function(a,b){return a+b;},0)/loads.length):0;\n $('summary').innerHTML =\n '<span class=\"chip ok\">nodes up <b>'+up+'</b></span>'\n + '<span class=\"chip'+(down?' bad':'')+'\">down <b>'+down+'</b></span>'\n + '<span class=\"chip info\">active runs <b>'+runs+'</b></span>'\n + '<span class=\"chip\">avg load <b>'+avg+'%</b></span>';\n $('fleetcount').textContent = '(' + nodes.length + ')';\n var self = s.self||{};\n var role = s.role||{};\n $('who').textContent = (self.displayName||'node') + ' \u00B7 ' + (self.role||'?') + (role.site?(' \u00B7 '+role.site):'') + ' \u00B7 v' + (self.version||'?');\n }\n\n function setLive(ok){\n $('livedot').classList.toggle('stale', !ok);\n $('offline').style.display = (!ok && everOk) ? 'block' : 'none';\n }\n\n function poll(){\n fetch('/fed/status'+QS, {cache:'no-store'}).then(function(r){\n if(!r.ok) throw new Error('status '+r.status);\n return r.json();\n }).then(function(s){\n everOk = true; setLive(true);\n if(!selfId && s.self) selfId = s.self.nodeId;\n if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }\n renderSummary(s);\n renderFleet(s.nodes||[]);\n renderHw(s.hardware);\n renderFeed(s.activity||[]);\n }).catch(function(){ setLive(false); });\n }\n\n // command execution\n function showOut(text){ $('outText').textContent = text; $('out').style.display='block'; }\n $('outClose').addEventListener('click', function(){ $('out').style.display='none'; });\n\n function runCommand(input){\n input = (input||'').trim();\n if(!input) return;\n // Target a peer by prefixing the kernel's node-scoped command form.\n var payload = selected ? ('/node ' + selected + ' ' + input) : input;\n $('runBtn').disabled = true;\n fetch('/fed/command'+QS, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ input: payload }) })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut((res && (res.text!=null?res.text:JSON.stringify(res,null,2))) || 'no output'); poll(); })\n .catch(function(e){ showOut('request failed: '+e.message); })\n .then(function(){ $('runBtn').disabled=false; });\n }\n\n $('runBtn').addEventListener('click', function(){ runCommand($('cmdInput').value); });\n $('cmdInput').addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ runCommand($('cmdInput').value); } });\n $('applyBtn').addEventListener('click', function(){\n $('applyBtn').disabled = true;\n fetch('/fed/apply'+QS, { method:'POST', headers:{'content-type':'application/json'}, body:'{}' })\n .then(function(r){ return r.json().catch(function(){ return { ok:false, text:'HTTP '+r.status }; }); })\n .then(function(res){ showOut('Apply config to fleet:\\n' + ((res&&(res.text!=null?res.text:JSON.stringify(res,null,2)))||'done')); })\n .catch(function(e){ showOut('apply failed: '+e.message); })\n .then(function(){ $('applyBtn').disabled=false; });\n });\n\n // \u2500\u2500 tabs: Fleet + RoboPark sessions/telemetry \u2500\u2500\n var tabs = document.querySelectorAll('.tab');\n var activeTab = 'fleet';\n for (var ti=0; ti<tabs.length; ti++){\n (function(btn){\n btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });\n })(tabs[ti]);\n }\n function selectTab(name){\n activeTab = name;\n for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);\n $('pane-fleet').classList.toggle('active', name==='fleet');\n $('pane-robopark').classList.toggle('active', name==='robopark');\n if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }\n if (name==='robopark') pollRobopark();\n }\n // deep-link: open the tab named in the URL hash on load\n if (location.hash.replace('#','') === 'robopark') selectTab('robopark');\n\n function robotsFrom(d){\n if (Array.isArray(d)) return d;\n if (d && Array.isArray(d.robots)) return d.robots;\n return [];\n }\n // Reflect this node's read-only state on the RoboPark controls.\n function applyReadOnly(){\n var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;\n var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;\n var note=$('rp-ro-note');\n if(rpReadOnly && !note){\n note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';\n note.textContent='read-only (no token) \u2014 start the hub with --token to enable control';\n var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);\n } else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }\n }\n\n // A write through the scheduler proxy. Handles the read-only 403 gracefully.\n function rpAction(method, path, body){\n if(rpReadOnly){ showOut('This node is read-only \u2014 restart the hub with --token to enable control actions.'); return Promise.resolve(null); }\n return fetch(path+QS, {\n method: method, cache:'no-store',\n headers: body?{'content-type':'application/json'}:undefined,\n body: body?JSON.stringify(body):undefined\n }).then(function(r){\n if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }\n return r.json().catch(function(){ return {}; }).then(function(j){ if(!r.ok) throw new Error((j&&(j.error||j.detail))||('status '+r.status)); return j; });\n });\n }\n\n // production_mode master toggle\n function setPm(on){\n var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');\n if(!wrap) return;\n wrap.hidden=false;\n tg.setAttribute('aria-checked', on?'true':'false');\n st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');\n }\n (function(){\n var tg=$('pm-toggle'); if(!tg) return;\n tg.addEventListener('click', function(){\n if(rpReadOnly||pmBusy) return;\n var next = tg.getAttribute('aria-checked')!=='true';\n pmBusy=true; tg.disabled=true;\n rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){\n setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);\n }).catch(function(e){ showOut('production_mode: '+e.message); })\n .then(function(){ pmBusy=false; applyReadOnly(); });\n });\n })();\n function pollSettings(){\n fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){\n if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);\n }).catch(function(){});\n }\n\n function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }\n function endSession(id){\n if(!confirm('End the active session for '+id+'?')) return;\n rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){\n showOut('ended session for '+id); pollRobopark();\n }).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });\n }\n // event-delegated action buttons (cards are re-rendered each poll)\n $('rp-grid').addEventListener('click', function(e){\n var b=e.target.closest?e.target.closest('.rp-btn'):null; if(!b) return;\n var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');\n if(act==='end') endSession(id);\n });\n\n function pollRobopark(){\n pollSettings();\n fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){\n var grid = $('rp-grid'), totals = $('rp-totals');\n if (d && (d.disabled || d.ok===false)){\n totals.innerHTML=''; $('pm-wrap').hidden=true;\n grid.innerHTML = '<div class=\"empty\" style=\"grid-column:1/-1\">'+esc(d.error||'no scheduler linked')+' \u2014 start the hub with <span class=\"mono\">--scheduler-url http://<scheduler>:8080</span></div>';\n return;\n }\n var robots = robotsFrom(d);\n var tot = (d && d.totals) || {};\n var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);\n var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);\n totals.innerHTML =\n '<span class=\"chip\">robots <b>'+robots.length+'</b></span>'\n + '<span class=\"chip\">sessions <b>'+totalSessions+'</b></span>'\n + '<span class=\"chip\">triggers <b>'+totalTrig+'</b></span>';\n if (!robots.length){ grid.innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">No robot telemetry yet \u2014 it populates as robots trigger sessions.</div>'; return; }\n var html='';\n for (var i=0;i<robots.length;i++){\n var x = robots[i];\n var name = robotName(x);\n var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'\u2014';\n var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';\n var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'\u2014';\n var active = x.active_session||x.in_session||x.session_active;\n var idAttr = esc(name);\n html += '<div class=\"rp-card\"><h4>'+esc(name)+(active?' <span class=\"chip\" style=\"color:var(--ok)\">live</span>':'')+'</h4><div class=\"rp-metrics\">'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class=\"l\">triggers</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class=\"l\">sessions</span></div>'\n + '<div class=\"rp-m\"><span class=\"v\">'+lat+'</span><span class=\"l\">avg latency</span></div>'\n + '<div class=\"rp-m\"><span class=\"v '+dcls+'\">'+drop+'</span><span class=\"l\">drop rate</span></div>'\n + '</div><div class=\"rp-actions\">'\n + '<button class=\"rp-btn danger\" data-act=\"end\" data-robot=\"'+idAttr+'\"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'\n + '</div></div>';\n }\n grid.innerHTML = html;\n }).catch(function(){\n $('rp-grid').innerHTML='<div class=\"empty\" style=\"grid-column:1/-1\">telemetry unreachable</div>';\n });\n }\n\n poll();\n setInterval(poll, 2000);\n setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);\n})();\n</script>\n</body>\n</html>";
|
|
@@ -131,6 +131,40 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
131
131
|
white-space:pre-wrap;display:none;box-shadow:0 8px 30px rgba(0,0,0,0.25);z-index:7;}
|
|
132
132
|
#out .x{float:right;cursor:pointer;color:var(--muted);border:none;background:none;font-family:var(--mono);}
|
|
133
133
|
.offline{background:var(--bad-bg);color:var(--bad);text-align:center;font-family:var(--mono);font-size:0.72rem;padding:0.4rem;display:none;}
|
|
134
|
+
|
|
135
|
+
/* tabs + product panes */
|
|
136
|
+
.tabs{display:flex;gap:0.2rem;padding:0 1.1rem;border-bottom:1px solid var(--border);background:var(--panel);position:sticky;top:53px;z-index:4;}
|
|
137
|
+
.tab{font-family:var(--mono);font-size:0.74rem;padding:0.6rem 0.95rem;cursor:pointer;color:var(--muted);background:none;border:none;border-bottom:2px solid transparent;}
|
|
138
|
+
.tab.active{color:var(--gold-bright);border-bottom-color:var(--gold-bright);}
|
|
139
|
+
.tab:focus-visible{outline:2px solid var(--gold-bright);outline-offset:-2px;}
|
|
140
|
+
.pane{display:none;} .pane.active{display:block;}
|
|
141
|
+
.rp-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:0.6rem;padding:1rem 1.1rem 0.2rem;}
|
|
142
|
+
.rp-totals{display:flex;gap:0.5rem;flex-wrap:wrap;}
|
|
143
|
+
.rp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:0.75rem;padding:0.8rem 1.1rem;}
|
|
144
|
+
.rp-card{background:var(--panel);border:1px solid var(--border);border-left:4px solid var(--gold-bright);border-radius:9px;padding:0.75rem 0.85rem;}
|
|
145
|
+
.rp-card h4{font-family:var(--mono);font-size:0.86rem;margin-bottom:0.5rem;word-break:break-word;}
|
|
146
|
+
.rp-metrics{display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.6rem;}
|
|
147
|
+
.rp-m{display:flex;flex-direction:column;}
|
|
148
|
+
.rp-m .v{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:1.1rem;color:var(--ink);}
|
|
149
|
+
.rp-m .l{font-size:0.62rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
|
|
150
|
+
.rp-m .v.warn{color:var(--warn);} .rp-m .v.bad{color:var(--bad);} .rp-m .v.ok{color:var(--ok);}
|
|
151
|
+
.rp-controls{display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;}
|
|
152
|
+
.pm{display:flex;align-items:center;gap:0.5rem;font-family:var(--mono);font-size:0.72rem;color:var(--muted);}
|
|
153
|
+
.pm .state{font-weight:700;letter-spacing:0.03em;}
|
|
154
|
+
.pm .state.on{color:var(--ok);} .pm .state.off{color:var(--muted);}
|
|
155
|
+
.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;}
|
|
156
|
+
.toggle::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--muted);transition:transform .15s,background .15s;}
|
|
157
|
+
.toggle[aria-checked="true"]{background:var(--ok-bg);border-color:var(--ok);}
|
|
158
|
+
.toggle[aria-checked="true"]::after{transform:translateX(20px);background:var(--ok);}
|
|
159
|
+
.toggle:disabled{opacity:0.45;cursor:default;}
|
|
160
|
+
.toggle:focus-visible{outline:2px solid var(--gold-bright);outline-offset:2px;}
|
|
161
|
+
.rp-card .rp-actions{display:flex;gap:0.4rem;margin-top:0.6rem;}
|
|
162
|
+
.rp-btn{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.55rem;border-radius:6px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;}
|
|
163
|
+
.rp-btn:hover{border-color:var(--gold-bright);color:var(--gold-bright);}
|
|
164
|
+
.rp-btn.danger:hover{border-color:var(--bad);color:var(--bad);}
|
|
165
|
+
.rp-btn:disabled{opacity:0.4;cursor:default;}
|
|
166
|
+
.rp-btn:focus-visible{outline:2px solid var(--gold-bright);outline-offset:1px;}
|
|
167
|
+
.rp-ro{font-family:var(--mono);font-size:0.66rem;color:var(--warn);}
|
|
134
168
|
</style>
|
|
135
169
|
</head>
|
|
136
170
|
<body>
|
|
@@ -147,6 +181,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
147
181
|
<button class="themebtn" id="theme">Theme</button>
|
|
148
182
|
</header>
|
|
149
183
|
|
|
184
|
+
<div class="tabs">
|
|
185
|
+
<button class="tab active" data-tab="fleet" id="tabbtn-fleet">Fleet</button>
|
|
186
|
+
<button class="tab" data-tab="robopark" id="tabbtn-robopark">Sessions & Telemetry</button>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div class="pane active" id="pane-fleet">
|
|
150
190
|
<div class="wrap">
|
|
151
191
|
<section>
|
|
152
192
|
<h2>Fleet <span class="count" id="fleetcount"></span></h2>
|
|
@@ -158,6 +198,22 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
158
198
|
<div class="feed" id="feed"></div>
|
|
159
199
|
</aside>
|
|
160
200
|
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div class="pane" id="pane-robopark">
|
|
204
|
+
<div class="rp-head">
|
|
205
|
+
<h2 style="margin:0">RoboPark · sessions & telemetry</h2>
|
|
206
|
+
<div class="rp-controls">
|
|
207
|
+
<span class="pm" id="pm-wrap" hidden>
|
|
208
|
+
<span>production mode</span>
|
|
209
|
+
<button class="toggle" id="pm-toggle" role="switch" aria-checked="false" title="master enable — robots only join sessions when ON"></button>
|
|
210
|
+
<span class="state off" id="pm-state">off</span>
|
|
211
|
+
</span>
|
|
212
|
+
<div class="rp-totals" id="rp-totals"></div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div class="rp-grid" id="rp-grid"></div>
|
|
216
|
+
</div>
|
|
161
217
|
|
|
162
218
|
<footer class="cmd">
|
|
163
219
|
<span class="sel">on <b id="selName">this node</b></span>
|
|
@@ -172,6 +228,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
172
228
|
var TOKEN = new URLSearchParams(location.search).get('token') || '';
|
|
173
229
|
var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
|
|
174
230
|
var selfId = null, selected = null, lastSeq = -1, everOk = false;
|
|
231
|
+
var rpReadOnly = false, pmBusy = false;
|
|
175
232
|
|
|
176
233
|
var $ = function(id){ return document.getElementById(id); };
|
|
177
234
|
function esc(s){ var d=document.createElement('div'); d.textContent = (s==null?'':String(s)); return d.innerHTML; }
|
|
@@ -328,6 +385,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
328
385
|
}).then(function(s){
|
|
329
386
|
everOk = true; setLive(true);
|
|
330
387
|
if(!selfId && s.self) selfId = s.self.nodeId;
|
|
388
|
+
if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
|
|
331
389
|
renderSummary(s);
|
|
332
390
|
renderFleet(s.nodes||[]);
|
|
333
391
|
renderHw(s.hardware);
|
|
@@ -363,8 +421,140 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
363
421
|
.then(function(){ $('applyBtn').disabled=false; });
|
|
364
422
|
});
|
|
365
423
|
|
|
424
|
+
// ── tabs: Fleet + RoboPark sessions/telemetry ──
|
|
425
|
+
var tabs = document.querySelectorAll('.tab');
|
|
426
|
+
var activeTab = 'fleet';
|
|
427
|
+
for (var ti=0; ti<tabs.length; ti++){
|
|
428
|
+
(function(btn){
|
|
429
|
+
btn.addEventListener('click', function(){ selectTab(btn.getAttribute('data-tab')); });
|
|
430
|
+
})(tabs[ti]);
|
|
431
|
+
}
|
|
432
|
+
function selectTab(name){
|
|
433
|
+
activeTab = name;
|
|
434
|
+
for (var i=0;i<tabs.length;i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab')===name);
|
|
435
|
+
$('pane-fleet').classList.toggle('active', name==='fleet');
|
|
436
|
+
$('pane-robopark').classList.toggle('active', name==='robopark');
|
|
437
|
+
if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
|
|
438
|
+
if (name==='robopark') pollRobopark();
|
|
439
|
+
}
|
|
440
|
+
// deep-link: open the tab named in the URL hash on load
|
|
441
|
+
if (location.hash.replace('#','') === 'robopark') selectTab('robopark');
|
|
442
|
+
|
|
443
|
+
function robotsFrom(d){
|
|
444
|
+
if (Array.isArray(d)) return d;
|
|
445
|
+
if (d && Array.isArray(d.robots)) return d.robots;
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
// Reflect this node's read-only state on the RoboPark controls.
|
|
449
|
+
function applyReadOnly(){
|
|
450
|
+
var t=$('pm-toggle'); if(t) t.disabled = rpReadOnly || pmBusy;
|
|
451
|
+
var btns=document.querySelectorAll('.rp-btn'); for(var i=0;i<btns.length;i++) btns[i].disabled = rpReadOnly;
|
|
452
|
+
var note=$('rp-ro-note');
|
|
453
|
+
if(rpReadOnly && !note){
|
|
454
|
+
note=document.createElement('span'); note.id='rp-ro-note'; note.className='rp-ro';
|
|
455
|
+
note.textContent='read-only (no token) — start the hub with --token to enable control';
|
|
456
|
+
var wrap=$('pm-wrap'); if(wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap);
|
|
457
|
+
} else if(!rpReadOnly && note){ note.parentNode.removeChild(note); }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// A write through the scheduler proxy. Handles the read-only 403 gracefully.
|
|
461
|
+
function rpAction(method, path, body){
|
|
462
|
+
if(rpReadOnly){ showOut('This node is read-only — restart the hub with --token to enable control actions.'); return Promise.resolve(null); }
|
|
463
|
+
return fetch(path+QS, {
|
|
464
|
+
method: method, cache:'no-store',
|
|
465
|
+
headers: body?{'content-type':'application/json'}:undefined,
|
|
466
|
+
body: body?JSON.stringify(body):undefined
|
|
467
|
+
}).then(function(r){
|
|
468
|
+
if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }
|
|
469
|
+
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; });
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// production_mode master toggle
|
|
474
|
+
function setPm(on){
|
|
475
|
+
var wrap=$('pm-wrap'), st=$('pm-state'), tg=$('pm-toggle');
|
|
476
|
+
if(!wrap) return;
|
|
477
|
+
wrap.hidden=false;
|
|
478
|
+
tg.setAttribute('aria-checked', on?'true':'false');
|
|
479
|
+
st.textContent = on?'on':'off'; st.className='state '+(on?'on':'off');
|
|
480
|
+
}
|
|
481
|
+
(function(){
|
|
482
|
+
var tg=$('pm-toggle'); if(!tg) return;
|
|
483
|
+
tg.addEventListener('click', function(){
|
|
484
|
+
if(rpReadOnly||pmBusy) return;
|
|
485
|
+
var next = tg.getAttribute('aria-checked')!=='true';
|
|
486
|
+
pmBusy=true; tg.disabled=true;
|
|
487
|
+
rpAction('PUT','/robopark/api/settings',{production_mode:next}).then(function(res){
|
|
488
|
+
setPm(res&&typeof res.production_mode!=='undefined'?res.production_mode:next);
|
|
489
|
+
}).catch(function(e){ showOut('production_mode: '+e.message); })
|
|
490
|
+
.then(function(){ pmBusy=false; applyReadOnly(); });
|
|
491
|
+
});
|
|
492
|
+
})();
|
|
493
|
+
function pollSettings(){
|
|
494
|
+
fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){
|
|
495
|
+
if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);
|
|
496
|
+
}).catch(function(){});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }
|
|
500
|
+
function endSession(id){
|
|
501
|
+
if(!confirm('End the active session for '+id+'?')) return;
|
|
502
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(id)+'/end-session?reason=operator').then(function(){
|
|
503
|
+
showOut('ended session for '+id); pollRobopark();
|
|
504
|
+
}).catch(function(e){ showOut('end-session ('+id+'): '+e.message); });
|
|
505
|
+
}
|
|
506
|
+
// event-delegated action buttons (cards are re-rendered each poll)
|
|
507
|
+
$('rp-grid').addEventListener('click', function(e){
|
|
508
|
+
var b=e.target.closest?e.target.closest('.rp-btn'):null; if(!b) return;
|
|
509
|
+
var id=b.getAttribute('data-robot'), act=b.getAttribute('data-act');
|
|
510
|
+
if(act==='end') endSession(id);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
function pollRobopark(){
|
|
514
|
+
pollSettings();
|
|
515
|
+
fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
|
|
516
|
+
var grid = $('rp-grid'), totals = $('rp-totals');
|
|
517
|
+
if (d && (d.disabled || d.ok===false)){
|
|
518
|
+
totals.innerHTML=''; $('pm-wrap').hidden=true;
|
|
519
|
+
grid.innerHTML = '<div class="empty" style="grid-column:1/-1">'+esc(d.error||'no scheduler linked')+' — start the hub with <span class="mono">--scheduler-url http://<scheduler>:8080</span></div>';
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
var robots = robotsFrom(d);
|
|
523
|
+
var tot = (d && d.totals) || {};
|
|
524
|
+
var totalSessions = tot.sessions_total!=null?tot.sessions_total:robots.reduce(function(a,x){return a+(x.sessions_total||0);},0);
|
|
525
|
+
var totalTrig = tot.trigger_count!=null?tot.trigger_count:robots.reduce(function(a,x){return a+(x.trigger_count||0);},0);
|
|
526
|
+
totals.innerHTML =
|
|
527
|
+
'<span class="chip">robots <b>'+robots.length+'</b></span>'
|
|
528
|
+
+ '<span class="chip">sessions <b>'+totalSessions+'</b></span>'
|
|
529
|
+
+ '<span class="chip">triggers <b>'+totalTrig+'</b></span>';
|
|
530
|
+
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; }
|
|
531
|
+
var html='';
|
|
532
|
+
for (var i=0;i<robots.length;i++){
|
|
533
|
+
var x = robots[i];
|
|
534
|
+
var name = robotName(x);
|
|
535
|
+
var lat = x.avg_latency_ms!=null?Math.round(x.avg_latency_ms)+'ms':'—';
|
|
536
|
+
var dcls = x.drop_rate!=null?(x.drop_rate>=0.2?'bad':(x.drop_rate>=0.05?'warn':'ok')):'';
|
|
537
|
+
var drop = x.drop_rate!=null?Math.round(x.drop_rate*100)+'%':'—';
|
|
538
|
+
var active = x.active_session||x.in_session||x.session_active;
|
|
539
|
+
var idAttr = esc(name);
|
|
540
|
+
html += '<div class="rp-card"><h4>'+esc(name)+(active?' <span class="chip" style="color:var(--ok)">live</span>':'')+'</h4><div class="rp-metrics">'
|
|
541
|
+
+ '<div class="rp-m"><span class="v">'+(x.trigger_count!=null?x.trigger_count:0)+'</span><span class="l">triggers</span></div>'
|
|
542
|
+
+ '<div class="rp-m"><span class="v">'+(x.sessions_total!=null?x.sessions_total:0)+'</span><span class="l">sessions</span></div>'
|
|
543
|
+
+ '<div class="rp-m"><span class="v">'+lat+'</span><span class="l">avg latency</span></div>'
|
|
544
|
+
+ '<div class="rp-m"><span class="v '+dcls+'">'+drop+'</span><span class="l">drop rate</span></div>'
|
|
545
|
+
+ '</div><div class="rp-actions">'
|
|
546
|
+
+ '<button class="rp-btn danger" data-act="end" data-robot="'+idAttr+'"'+((rpReadOnly||!active)?' disabled':'')+'>End session</button>'
|
|
547
|
+
+ '</div></div>';
|
|
548
|
+
}
|
|
549
|
+
grid.innerHTML = html;
|
|
550
|
+
}).catch(function(){
|
|
551
|
+
$('rp-grid').innerHTML='<div class="empty" style="grid-column:1/-1">telemetry unreachable</div>';
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
366
555
|
poll();
|
|
367
556
|
setInterval(poll, 2000);
|
|
557
|
+
setInterval(function(){ if (activeTab==='robopark') pollRobopark(); }, 3000);
|
|
368
558
|
})();
|
|
369
559
|
</script>
|
|
370
560
|
</body>
|
|
@@ -74,6 +74,17 @@ export interface FederationOptions {
|
|
|
74
74
|
ok: boolean;
|
|
75
75
|
reason?: string;
|
|
76
76
|
};
|
|
77
|
+
/** TLS material — when set, the node + dashboard are served over HTTPS. */
|
|
78
|
+
tls?: {
|
|
79
|
+
cert: Buffer;
|
|
80
|
+
key: Buffer;
|
|
81
|
+
};
|
|
82
|
+
/** RoboPark scheduler base URL — proxied under /robopark/* for the dashboard. */
|
|
83
|
+
schedulerUrl?: string;
|
|
84
|
+
/** Bearer token presented to the scheduler on proxied requests. */
|
|
85
|
+
schedulerToken?: string;
|
|
86
|
+
/** Refuse write actions through the scheduler proxy (read-only console). */
|
|
87
|
+
schedulerReadOnly?: boolean;
|
|
77
88
|
}
|
|
78
89
|
/** Consolidated fleet view served at GET /fed/status. */
|
|
79
90
|
export interface FleetStatus {
|
|
@@ -87,6 +98,12 @@ export interface FleetStatus {
|
|
|
87
98
|
peerId: string;
|
|
88
99
|
f: StreamFrame;
|
|
89
100
|
}>;
|
|
101
|
+
/** Console capabilities for the dashboard: whether control writes are allowed
|
|
102
|
+
* on this node and whether a RoboPark scheduler is linked. */
|
|
103
|
+
control: {
|
|
104
|
+
readonly: boolean;
|
|
105
|
+
schedulerLinked: boolean;
|
|
106
|
+
};
|
|
90
107
|
ts: number;
|
|
91
108
|
}
|
|
92
109
|
export interface FederationDeps {
|
|
@@ -78,6 +78,10 @@ export class Federation {
|
|
|
78
78
|
commandGate: options.commandGate,
|
|
79
79
|
onApply: options.onApply,
|
|
80
80
|
dashboardHtml: options.dashboard ? DASHBOARD_HTML : undefined,
|
|
81
|
+
tls: options.tls,
|
|
82
|
+
schedulerUrl: options.schedulerUrl,
|
|
83
|
+
schedulerToken: options.schedulerToken,
|
|
84
|
+
schedulerReadOnly: options.schedulerReadOnly,
|
|
81
85
|
onRpc: (env, remote) => this.onRpc(env, remote),
|
|
82
86
|
});
|
|
83
87
|
await this.server.start();
|
|
@@ -677,6 +681,10 @@ export class Federation {
|
|
|
677
681
|
runs: this.localRuns(),
|
|
678
682
|
agents: this.localAgents(),
|
|
679
683
|
activity: this.recentFrames(60),
|
|
684
|
+
control: {
|
|
685
|
+
readonly: Boolean(this.deps.options.schedulerReadOnly),
|
|
686
|
+
schedulerLinked: Boolean(this.deps.options.schedulerUrl),
|
|
687
|
+
},
|
|
680
688
|
ts: Date.now(),
|
|
681
689
|
};
|
|
682
690
|
}
|
|
@@ -25,6 +25,21 @@ export interface MeshServerOptions {
|
|
|
25
25
|
onApply?: () => Promise<unknown> | unknown;
|
|
26
26
|
/** Optional: static HTML for the control dashboard, served at GET / and /ui. */
|
|
27
27
|
dashboardHtml?: string;
|
|
28
|
+
/** Optional TLS — when set, the node serves HTTPS/WSS instead of plain HTTP. */
|
|
29
|
+
tls?: {
|
|
30
|
+
cert: Buffer;
|
|
31
|
+
key: Buffer;
|
|
32
|
+
};
|
|
33
|
+
/** Optional: base URL of the RoboPark scheduler. When set, the node proxies
|
|
34
|
+
* GET/POST /robopark/* to it so the dashboard can show sessions + telemetry
|
|
35
|
+
* (product data) alongside the mesh. */
|
|
36
|
+
schedulerUrl?: string;
|
|
37
|
+
/** Optional bearer token to present to the scheduler on proxied requests. */
|
|
38
|
+
schedulerToken?: string;
|
|
39
|
+
/** When true, only GET/HEAD are proxied to the scheduler — write actions
|
|
40
|
+
* (end-session, production_mode toggle, trigger) are refused. Set for a
|
|
41
|
+
* network-exposed, tokenless node so the operator console stays read-only. */
|
|
42
|
+
schedulerReadOnly?: boolean;
|
|
28
43
|
onRpc: RpcHandler;
|
|
29
44
|
logger: Logger;
|
|
30
45
|
}
|
|
@@ -36,6 +51,8 @@ export declare class MeshServer {
|
|
|
36
51
|
constructor(opts: MeshServerOptions);
|
|
37
52
|
get streamCount(): number;
|
|
38
53
|
start(): Promise<void>;
|
|
54
|
+
/** Proxy a dashboard request to the RoboPark scheduler (product data). */
|
|
55
|
+
private proxyToScheduler;
|
|
39
56
|
/** Push a frame to every connected stream subscriber. */
|
|
40
57
|
broadcast(frame: StreamFrame): void;
|
|
41
58
|
/** Token presented on a request — Bearer header (peers/CLI) OR ?token= query
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* MeshClient/MeshServer shape later for deep InfiniBot-fabric interop.
|
|
15
15
|
*/
|
|
16
16
|
import { createServer } from 'node:http';
|
|
17
|
+
import { createServer as createHttpsServer } from 'node:https';
|
|
17
18
|
import { timingSafeEqual } from 'node:crypto';
|
|
18
19
|
import { frameToSSE, parseSSE } from './protocol.js';
|
|
19
20
|
/** Constant-time string compare — avoids leaking the token via response timing. */
|
|
@@ -36,7 +37,10 @@ export class MeshServer {
|
|
|
36
37
|
return this.sse.size;
|
|
37
38
|
}
|
|
38
39
|
async start() {
|
|
39
|
-
|
|
40
|
+
const handler = (req, res) => this.handle(req, res);
|
|
41
|
+
this.server = this.opts.tls
|
|
42
|
+
? createHttpsServer({ cert: this.opts.tls.cert, key: this.opts.tls.key }, handler)
|
|
43
|
+
: createServer(handler);
|
|
40
44
|
await new Promise((resolve, reject) => {
|
|
41
45
|
this.server.once('error', reject);
|
|
42
46
|
this.server.listen(this.opts.port, this.opts.host ?? '0.0.0.0', () => {
|
|
@@ -44,7 +48,34 @@ export class MeshServer {
|
|
|
44
48
|
resolve();
|
|
45
49
|
});
|
|
46
50
|
});
|
|
47
|
-
|
|
51
|
+
const scheme = this.opts.tls ? 'https' : 'http';
|
|
52
|
+
this.opts.logger.info(`[federation] mesh listening on ${scheme}://${this.opts.host ?? '0.0.0.0'}:${this.opts.port}`);
|
|
53
|
+
}
|
|
54
|
+
/** Proxy a dashboard request to the RoboPark scheduler (product data). */
|
|
55
|
+
proxyToScheduler(req, res, path) {
|
|
56
|
+
const base = this.opts.schedulerUrl.replace(/\/+$/, '');
|
|
57
|
+
// strip the /robopark prefix; forward the rest (incl. query) to the scheduler
|
|
58
|
+
const rest = path.replace(/^\/robopark/, '') || '/';
|
|
59
|
+
const target = `${base}${rest}`;
|
|
60
|
+
this.readBody(req)
|
|
61
|
+
.then(async (body) => {
|
|
62
|
+
const headers = { 'content-type': req.headers['content-type'] ?? 'application/json' };
|
|
63
|
+
if (this.opts.schedulerToken)
|
|
64
|
+
headers['authorization'] = `Bearer ${this.opts.schedulerToken}`;
|
|
65
|
+
const upstream = await fetch(target, {
|
|
66
|
+
method: req.method,
|
|
67
|
+
headers,
|
|
68
|
+
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
|
|
69
|
+
signal: AbortSignal.timeout(10_000),
|
|
70
|
+
});
|
|
71
|
+
const text = await upstream.text();
|
|
72
|
+
res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' });
|
|
73
|
+
res.end(text);
|
|
74
|
+
})
|
|
75
|
+
.catch(err => {
|
|
76
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
77
|
+
res.end(JSON.stringify({ ok: false, error: `scheduler unreachable: ${err instanceof Error ? err.message : String(err)}` }));
|
|
78
|
+
});
|
|
48
79
|
}
|
|
49
80
|
/** Push a frame to every connected stream subscriber. */
|
|
50
81
|
broadcast(frame) {
|
|
@@ -105,6 +136,25 @@ export class MeshServer {
|
|
|
105
136
|
res.end(JSON.stringify(this.opts.getStatus?.() ?? {}));
|
|
106
137
|
return;
|
|
107
138
|
}
|
|
139
|
+
// RoboPark product data (sessions, telemetry, robots) — proxied to the
|
|
140
|
+
// scheduler so the dashboard is one pane of glass. Already token-gated above.
|
|
141
|
+
if (path.startsWith('/robopark/')) {
|
|
142
|
+
if (!this.opts.schedulerUrl) {
|
|
143
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
144
|
+
res.end(JSON.stringify({ ok: false, disabled: true, error: 'no scheduler linked — start with --scheduler-url' }));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Keep a network-exposed, tokenless node read-only: mesh writes are gated
|
|
148
|
+
// by commandGate; the scheduler proxy is the product-side equivalent.
|
|
149
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
150
|
+
if (this.opts.schedulerReadOnly && method !== 'GET' && method !== 'HEAD') {
|
|
151
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
152
|
+
res.end(JSON.stringify({ ok: false, readonly: true, error: 'this node is network-exposed without a token — control actions are disabled. Restart with --token <secret> to enable them.' }));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.proxyToScheduler(req, res, path);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
108
158
|
if (req.method === 'POST' && path.startsWith('/fed/apply')) {
|
|
109
159
|
if (!this.opts.onApply) {
|
|
110
160
|
res.writeHead(404).end('no apply handler');
|
package/dist/robopark-cli.js
CHANGED
|
@@ -9,7 +9,7 @@ import { Command } from 'commander';
|
|
|
9
9
|
import Conf from 'conf';
|
|
10
10
|
import { attachRoboparkSubcommands } from './commands/robopark.js';
|
|
11
11
|
import { DEFAULT_CONFIG } from './kernel/config-schema.js';
|
|
12
|
-
const VERSION = '2.
|
|
12
|
+
const VERSION = '2.5.1';
|
|
13
13
|
const config = new Conf({
|
|
14
14
|
projectName: 'infinicode', // share infinicode's config store (providers, federation)
|
|
15
15
|
defaults: DEFAULT_CONFIG,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinicode",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/kernel/index.js",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"build": "tsc",
|
|
35
35
|
"dev": "tsc --watch",
|
|
36
36
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
37
|
+
"test": "npm run build && node --test \"test/**/*.test.mjs\"",
|
|
37
38
|
"prepublishOnly": "npm run build",
|
|
38
39
|
"kernel": "node bin/infinicode.js mission",
|
|
39
40
|
"typecheck": "tsc --noEmit"
|