infinicode 2.8.37 → 2.8.38
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/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +1041 -28
- package/dist/robopark/add-robot.d.ts +2 -0
- package/dist/robopark/add-robot.js +9 -0
- package/dist/robopark/secrets.d.ts +8 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/setup.d.ts +3 -0
- package/dist/robopark/setup.js +9 -2
- package/dist/robopark-cli.js +22 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +770 -114
- package/packages/robopark/scheduler/robot_supervisor.py +424 -15
|
@@ -333,6 +333,89 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
333
333
|
.pk-svc-meta{color:var(--muted);flex:1;}
|
|
334
334
|
.pk-svc-restart-btn{font-size:0.6rem;padding:0.2rem 0.5rem;flex-shrink:0;}
|
|
335
335
|
|
|
336
|
+
/* ── C3: operator identity (top-bar badge + audit attribution) ── */
|
|
337
|
+
.rp-operator-badge{font-family:var(--mono);font-size:0.66rem;padding:0.3rem 0.6rem;border-radius:7px;border:1px solid var(--border-strong);background:var(--panel-2);color:var(--ink);cursor:pointer;margin-left:0.4rem;user-select:none;}
|
|
338
|
+
.rp-operator-badge:hover{border-color:var(--gold-bright);color:var(--gold-bright);}
|
|
339
|
+
.rp-operator-badge.set{color:var(--muted);font-style:italic;}
|
|
340
|
+
.rp-operator-badge.set:hover{color:var(--gold-bright);}
|
|
341
|
+
.rp-actor-chip{font-family:var(--mono);font-size:0.62rem;padding:0.28em 0.7em;border-radius:100px;cursor:pointer;border:1px solid var(--border-strong);color:var(--muted);background:var(--panel-2);user-select:none;}
|
|
342
|
+
.rp-actor-chip:hover{color:var(--ink);border-color:var(--border-strong);}
|
|
343
|
+
.rp-actor-chip.active{color:var(--gold-bright);border-color:var(--gold-bright);background:color-mix(in srgb,var(--gold-bright) 10%,transparent);}
|
|
344
|
+
.rp-actor-chip.disabled{opacity:0.45;cursor:not-allowed;}
|
|
345
|
+
.rp-actor-mine{color:var(--gold-bright);}
|
|
346
|
+
.rp-actor-mine b{color:var(--gold-bright);}
|
|
347
|
+
.rp-actor-other{color:var(--muted);}
|
|
348
|
+
.rp-actor-other b{color:var(--ink);font-weight:500;}
|
|
349
|
+
.rp-audit-row-mine{background:color-mix(in srgb,var(--gold-bright) 6%,transparent);}
|
|
350
|
+
|
|
351
|
+
/* ── C4: Incidents sub-tab ── */
|
|
352
|
+
.rp-incidents-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:0.6rem;margin:0.5rem 0 0.8rem;}
|
|
353
|
+
.rp-inc-kpi{background:var(--panel-2);border:1px solid var(--border-strong);border-radius:8px;padding:0.55rem 0.7rem;display:flex;flex-direction:column;gap:0.15rem;}
|
|
354
|
+
.rp-inc-kpi .lab{font-family:var(--mono);font-size:0.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;display:flex;align-items:center;gap:0.35rem;}
|
|
355
|
+
.rp-inc-kpi .v{font-family:var(--mono);font-size:1.1rem;color:var(--ink);font-weight:600;}
|
|
356
|
+
.rp-inc-kpi .meta{font-family:var(--mono);font-size:0.58rem;color:var(--muted);}
|
|
357
|
+
.pk-kpi-dot{width:8px;height:8px;border-radius:50%;background:var(--muted);display:inline-block;}
|
|
358
|
+
.pk-kpi-dot.ok{background:var(--ok);box-shadow:0 0 6px var(--ok);}
|
|
359
|
+
.pk-kpi-dot.warn{background:var(--warn,#e0a23a);box-shadow:0 0 6px var(--warn,#e0a23a);}
|
|
360
|
+
.pk-kpi-dot.bad{background:var(--bad);box-shadow:0 0 6px var(--bad);}
|
|
361
|
+
.rp-inc-table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:0.66rem;}
|
|
362
|
+
.rp-inc-table th{text-align:left;padding:0.45rem 0.6rem;color:var(--muted);font-size:0.58rem;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid var(--glass-border);background:var(--panel-2);position:sticky;top:0;z-index:1;}
|
|
363
|
+
.rp-inc-table td{padding:0.45rem 0.6rem;border-bottom:1px solid rgba(255,255,255,0.04);vertical-align:middle;}
|
|
364
|
+
.rp-inc-table tr.rp-inc-sev-bad td{background:rgba(220,80,80,0.06);}
|
|
365
|
+
.rp-inc-table tr.rp-inc-sev-warn td{background:rgba(224,162,58,0.05);}
|
|
366
|
+
.rp-inc-sev{display:inline-block;padding:0.05em 0.55em;border-radius:5px;font-size:0.6rem;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;}
|
|
367
|
+
.rp-inc-sev-bad .rp-inc-sev{color:var(--bad);border:1px solid color-mix(in srgb,var(--bad) 40%,transparent);background:rgba(220,80,80,0.10);}
|
|
368
|
+
.rp-inc-sev-warn .rp-inc-sev{color:var(--warn,#e0a23a);border:1px solid color-mix(in srgb,var(--warn,#e0a23a) 40%,transparent);background:rgba(224,162,58,0.10);}
|
|
369
|
+
.rp-inc-sev-info .rp-inc-sev{color:var(--muted);border:1px solid var(--border-strong);background:var(--panel-2);}
|
|
370
|
+
.rp-inc-id{color:var(--muted);font-size:0.6rem;margin-left:0.4rem;}
|
|
371
|
+
.rp-inc-acked-tag{display:inline-block;padding:0.05em 0.5em;border-radius:5px;font-size:0.58rem;color:var(--ok);border:1px solid color-mix(in srgb,var(--ok) 40%,transparent);background:rgba(60,180,110,0.12);margin-left:0.5rem;}
|
|
372
|
+
.rp-inc-acked-row td{opacity:0.7;font-style:italic;}
|
|
373
|
+
.rp-btn.ghost{background:transparent;}
|
|
374
|
+
@media (max-width:900px){ .rp-incidents-kpis{grid-template-columns:repeat(2,1fr);} }
|
|
375
|
+
|
|
376
|
+
/* ── C2: speaker roundtrip test panel ── */
|
|
377
|
+
#pk-spk-test{font-family:var(--mono);}
|
|
378
|
+
#pk-spk-test-stateline{font-family:var(--mono);font-size:0.62rem;color:var(--muted);}
|
|
379
|
+
.pk-spk-result{margin-top:0.45rem;padding:0.55rem 0.65rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);font-family:var(--mono);font-size:0.66rem;}
|
|
380
|
+
.pk-spk-result.ok{border-color:var(--ok);background:rgba(60,180,110,0.10);}
|
|
381
|
+
.pk-spk-result.bad{border-color:var(--bad);background:rgba(220,80,80,0.10);}
|
|
382
|
+
.pk-spk-banner{font-weight:600;font-size:0.78rem;margin-bottom:0.35rem;}
|
|
383
|
+
.pk-spk-banner.ok{color:var(--ok);}
|
|
384
|
+
.pk-spk-banner.bad{color:var(--bad);}
|
|
385
|
+
.pk-spk-meta{display:grid;grid-template-columns:repeat(2,1fr);gap:0.2rem 0.6rem;margin:0.3rem 0;}
|
|
386
|
+
.pk-spk-meta>div{display:flex;justify-content:space-between;gap:0.4rem;}
|
|
387
|
+
.pk-spk-meta .l{color:var(--muted);text-transform:uppercase;font-size:0.58rem;letter-spacing:0.04em;}
|
|
388
|
+
.pk-spk-meta .v{color:var(--ink);font-size:0.66rem;}
|
|
389
|
+
.pk-spk-err{color:var(--bad);font-style:italic;margin-top:0.2rem;}
|
|
390
|
+
.pk-spk-diag{color:var(--muted);font-style:italic;margin-top:0.3rem;font-size:0.6rem;line-height:1.4;}
|
|
391
|
+
.pk-spk-hist-head{font-family:var(--mono);font-size:0.6rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-top:0.5rem;padding-top:0.4rem;border-top:1px solid var(--glass-border);}
|
|
392
|
+
.pk-spk-hist-row{display:grid;grid-template-columns:1em 4em 4em 8em 1fr;gap:0.45rem;padding:2px 0;font-family:var(--mono);font-size:0.62rem;align-items:baseline;}
|
|
393
|
+
.pk-spk-mark{font-weight:700;}
|
|
394
|
+
.pk-spk-when{color:var(--muted);}
|
|
395
|
+
.pk-spk-freq{color:var(--ink);}
|
|
396
|
+
.pk-spk-peak{color:var(--ink);}
|
|
397
|
+
|
|
398
|
+
/* ── C1: per-robot shell section in the Park drawer ── */
|
|
399
|
+
.pk-shell-row{display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;}
|
|
400
|
+
.pk-shell-lbl{font-family:var(--mono);font-size:0.62rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;}
|
|
401
|
+
.pk-shell-sel,.pk-shell-num{font-family:var(--mono);font-size:0.7rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:6px;padding:0.32rem 0.5rem;min-width:0;}
|
|
402
|
+
.pk-shell-sel{flex:1;max-width:220px;}
|
|
403
|
+
.pk-shell-num{width:5.5em;}
|
|
404
|
+
.pk-shell-out-wrap{margin-top:0.4rem;border:1px solid var(--glass-border);border-radius:8px;background:var(--panel-solid);overflow:hidden;}
|
|
405
|
+
.pk-shell-out{font-family:var(--mono);font-size:0.66rem;line-height:1.45;color:var(--ink);max-height:240px;overflow-y:auto;padding:0.45rem 0.6rem;background:#0c0e14;}
|
|
406
|
+
.pk-shell-out:focus{outline:none;}
|
|
407
|
+
.pk-shell-line{display:flex;gap:0.55rem;align-items:flex-start;padding:1px 0;}
|
|
408
|
+
.pk-shell-ts{color:var(--muted);flex-shrink:0;font-size:0.58rem;padding-top:0.15em;min-width:3.6em;}
|
|
409
|
+
.pk-shell-text{white-space:pre-wrap;word-break:break-word;flex:1;}
|
|
410
|
+
.pk-shell-status{color:var(--gold-bright,var(--gold));font-style:italic;}
|
|
411
|
+
.pk-shell-header{color:var(--muted);}
|
|
412
|
+
.pk-shell-stderr{color:var(--bad);}
|
|
413
|
+
.pk-shell-stdout{color:var(--ink);}
|
|
414
|
+
.pk-shell-input{color:var(--info);font-weight:600;}
|
|
415
|
+
.pk-shell-input .pk-shell-text::before{content:'$ ';color:var(--muted);font-weight:400;}
|
|
416
|
+
#pk-shell-stateline{font-family:var(--mono);font-size:0.62rem;color:var(--muted);}
|
|
417
|
+
#pk-shell-cmd{font-family:var(--mono);font-size:0.7rem;background:var(--panel-solid);border:1px solid var(--glass-border);color:var(--ink);border-radius:6px;padding:0.4rem 0.55rem;}
|
|
418
|
+
|
|
336
419
|
/* ── RoboPark Sessions & Telemetry — sub-tab control room ── */
|
|
337
420
|
.rp-subtabs{display:flex;gap:0.15rem;padding:0 1.1rem;border-bottom:1px solid var(--glass-border);
|
|
338
421
|
background:var(--panel);-webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3);
|
|
@@ -497,6 +580,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
497
580
|
</div>
|
|
498
581
|
<div class="chips" id="summary"></div>
|
|
499
582
|
<button class="themebtn" id="theme">Theme</button>
|
|
583
|
+
<span class="rp-operator-badge set" id="rp-operator-badge" role="button" tabindex="0">set operator</span>
|
|
500
584
|
</header>
|
|
501
585
|
|
|
502
586
|
<div class="tabs">
|
|
@@ -562,6 +646,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
562
646
|
<!-- Sub-tab nav bar (sticky, just under main tabs) -->
|
|
563
647
|
<div class="rp-subtabs" id="rp-subtabs" role="tablist">
|
|
564
648
|
<button class="rp-subtab active" data-sub="overview" id="rpsbtn-overview" role="tab" aria-selected="true">Overview</button>
|
|
649
|
+
<button class="rp-subtab" data-sub="incidents" id="rpsbtn-incidents" role="tab" aria-selected="false">Incidents <span class="badge" id="rps-incidents-badge">0</span></button>
|
|
565
650
|
<button class="rp-subtab" data-sub="live" id="rpsbtn-live" role="tab" aria-selected="false">Live Sessions <span class="badge" id="rps-live-badge">0</span></button>
|
|
566
651
|
<button class="rp-subtab" data-sub="history" id="rpsbtn-history" role="tab" aria-selected="false">History</button>
|
|
567
652
|
<button class="rp-subtab" data-sub="audit" id="rpsbtn-audit" role="tab" aria-selected="false">Audit Log</button>
|
|
@@ -654,12 +739,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
654
739
|
<span class="rp-cat-chip" data-cat="voice" id="rp-audit-cat-voice">🗣 Voice</span>
|
|
655
740
|
<span class="rp-cat-chip" data-cat="character" id="rp-audit-cat-character">🎭 Character</span>
|
|
656
741
|
<div style="flex:1;"></div>
|
|
742
|
+
<span class="rp-actor-chip" id="rp-audit-byme" data-actor="" title="Filter the audit log to actions attributed to the current operator">👤 by me</span>
|
|
657
743
|
<button class="rp-btn" id="rp-audit-refresh">↻ Refresh</button>
|
|
658
744
|
</div>
|
|
659
745
|
<div class="rp-tbl-wrap" id="rp-audit-wrap">
|
|
660
746
|
<table class="rp-audit-table" id="rp-audit-table">
|
|
661
747
|
<thead><tr>
|
|
662
|
-
<th>Time</th><th>Category</th><th>Action</th><th>Target</th><th>Details</th><th>
|
|
748
|
+
<th>Time</th><th>Category</th><th>Action</th><th>Target</th><th>Details</th><th>Operator</th>
|
|
663
749
|
</tr></thead>
|
|
664
750
|
<tbody id="rp-audit-tbody"></tbody>
|
|
665
751
|
</table>
|
|
@@ -667,6 +753,64 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
667
753
|
<div class="rp-tbl-empty" id="rp-audit-empty" style="display:none;">No audit events match the current filter.</div>
|
|
668
754
|
</div>
|
|
669
755
|
|
|
756
|
+
<!-- ═══ Incidents ═══ -->
|
|
757
|
+
<div class="rp-sub" id="rp-sub-incidents">
|
|
758
|
+
<div class="rp-section-h">Liveness <span class="meta" id="rp-incidents-meta">polling mesh + scheduler + devices</span></div>
|
|
759
|
+
<div class="rp-incidents-kpis" id="rp-incidents-kpis">
|
|
760
|
+
<div class="rp-inc-kpi" id="rp-inc-kpi-mesh">
|
|
761
|
+
<div class="lab">mesh <span class="pk-kpi-dot" id="rp-inc-kpi-mesh-dot"></span></div>
|
|
762
|
+
<div class="v" id="rp-inc-kpi-mesh-v">—</div>
|
|
763
|
+
<div class="meta" id="rp-inc-kpi-mesh-meta">awaiting first poll…</div>
|
|
764
|
+
</div>
|
|
765
|
+
<div class="rp-inc-kpi" id="rp-inc-kpi-sched">
|
|
766
|
+
<div class="lab">scheduler <span class="pk-kpi-dot" id="rp-inc-kpi-sched-dot"></span></div>
|
|
767
|
+
<div class="v" id="rp-inc-kpi-sched-v">—</div>
|
|
768
|
+
<div class="meta" id="rp-inc-kpi-sched-meta">awaiting first poll…</div>
|
|
769
|
+
</div>
|
|
770
|
+
<div class="rp-inc-kpi" id="rp-inc-kpi-cam">
|
|
771
|
+
<div class="lab">cameras <span class="pk-kpi-dot" id="rp-inc-kpi-cam-dot"></span></div>
|
|
772
|
+
<div class="v" id="rp-inc-kpi-cam-v">—</div>
|
|
773
|
+
<div class="meta" id="rp-inc-kpi-cam-meta">awaiting first session…</div>
|
|
774
|
+
</div>
|
|
775
|
+
<div class="rp-inc-kpi" id="rp-inc-kpi-prod">
|
|
776
|
+
<div class="lab">production mode <span class="pk-kpi-dot" id="rp-inc-kpi-prod-dot"></span></div>
|
|
777
|
+
<div class="v" id="rp-inc-kpi-prod-v">—</div>
|
|
778
|
+
<div class="meta" id="rp-inc-kpi-prod-meta">awaiting first poll…</div>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
782
|
+
<div class="rp-section-h" style="margin-top:1rem;">Active incidents <span class="meta" id="rp-incidents-count">0</span></div>
|
|
783
|
+
<div class="rp-filterbar" style="gap:0.4rem;">
|
|
784
|
+
<span class="rp-cat-chip active" data-inc-sev="" id="rp-inc-sev-all">All</span>
|
|
785
|
+
<span class="rp-cat-chip" data-inc-sev="bad" id="rp-inc-sev-bad">✗ critical</span>
|
|
786
|
+
<span class="rp-cat-chip" data-inc-sev="warn" id="rp-inc-sev-warn">⚠ warning</span>
|
|
787
|
+
<span class="rp-cat-chip" data-inc-sev="info" id="rp-inc-sev-info">· info</span>
|
|
788
|
+
<div style="flex:1"></div>
|
|
789
|
+
<button class="rp-btn" id="rp-inc-refresh">↻ Refresh</button>
|
|
790
|
+
</div>
|
|
791
|
+
<div class="rp-tbl-wrap" id="rp-inc-wrap" style="display:none;">
|
|
792
|
+
<table class="rp-inc-table" id="rp-inc-table">
|
|
793
|
+
<thead><tr>
|
|
794
|
+
<th>Severity</th><th>Robot</th><th>Readiness</th><th>First seen</th><th>Duration</th><th>Message</th><th></th>
|
|
795
|
+
</tr></thead>
|
|
796
|
+
<tbody id="rp-inc-tbody"></tbody>
|
|
797
|
+
</table>
|
|
798
|
+
</div>
|
|
799
|
+
<div class="rp-empty" id="rp-inc-empty">no active incidents</div>
|
|
800
|
+
|
|
801
|
+
<div class="rp-section-h" style="margin-top:1rem;">Acknowledged <span class="meta" id="rp-inc-ack-count">0</span></div>
|
|
802
|
+
<div class="rp-tbl-wrap" id="rp-inc-ack-wrap" style="display:none;">
|
|
803
|
+
<table class="rp-inc-table" id="rp-inc-ack-table">
|
|
804
|
+
<thead><tr>
|
|
805
|
+
<th>Acked at</th><th>By</th><th>Robot</th><th>Readiness</th><th>Message</th><th></th>
|
|
806
|
+
</tr></thead>
|
|
807
|
+
<tbody id="rp-inc-ack-tbody"></tbody>
|
|
808
|
+
</table>
|
|
809
|
+
</div>
|
|
810
|
+
<div class="rp-empty" id="rp-inc-ack-empty">no acknowledged incidents</div>
|
|
811
|
+
<div class="pk-note" style="margin-top:0.5rem;">incidents are derived from /api/telemetry readiness codes. Acknowledgments are stored in your browser (localStorage) so multiple operators on different machines each get their own list. When a robot's readiness returns to "ready" or "in_session" the incident auto-closes.</div>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
670
814
|
<!-- ═══ Fleet ═══ -->
|
|
671
815
|
<div class="rp-sub" id="rp-sub-fleet">
|
|
672
816
|
<div class="rp-section-h">Robots <span class="meta" id="rp-fleet-meta">live + idle + offline</span></div>
|
|
@@ -832,6 +976,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
832
976
|
var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
|
|
833
977
|
var selfId = null, selected = null, lastSeq = -1, everOk = false;
|
|
834
978
|
var rpReadOnly = false, pmBusy = false;
|
|
979
|
+
var lastSchedulerPollAt = 0; // updated by pollRobopark() on every successful /api/telemetry fetch
|
|
980
|
+
var lastFedStatusAt = 0; // updated by poll() on every successful /fed/status fetch
|
|
835
981
|
var lastStatus = null; // most recent /fed/status snapshot (shared with Park)
|
|
836
982
|
var roboparkByName = {}; // robot_id -> latest telemetry row (shared with Park drawer)
|
|
837
983
|
var schedulerDevicesById = {}; // enrolled device rows + camera/audio inventory
|
|
@@ -1084,13 +1230,91 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1084
1230
|
// Scheduler-enrolled robots are not necessarily infinicode mesh peers. Add
|
|
1085
1231
|
// them to the same Fleet/Park collection so a robot running only the
|
|
1086
1232
|
// preview agent is still visible and selectable in the full dashboard.
|
|
1233
|
+
// ── C3: Operator identity ──
|
|
1234
|
+
// The dashboard is a single-tenant surface (one hub = one operator
|
|
1235
|
+
// group), so we don't have full RBAC. We DO have a per-action
|
|
1236
|
+
// attribution problem: when an operator clicks "End session" the
|
|
1237
|
+
// audit log needs to know who. The scheduler logs whatever actor
|
|
1238
|
+
// string it sees, so we set an X-Operator header on every API
|
|
1239
|
+
// call from a name the operator typed into a profile modal on
|
|
1240
|
+
// first load. The name is editable from the top bar.
|
|
1241
|
+
var OPERATOR_NAME_KEY = 'rp-operator-name-v1';
|
|
1242
|
+
var _operatorName = '';
|
|
1243
|
+
function getOperatorName(){ return _operatorName || ''; }
|
|
1244
|
+
function setOperatorName(name){
|
|
1245
|
+
var s = String(name == null ? '' : name).trim();
|
|
1246
|
+
if(s.length > 64) s = s.slice(0, 64);
|
|
1247
|
+
_operatorName = s;
|
|
1248
|
+
try{ if(s) localStorage.setItem(OPERATOR_NAME_KEY, s); else localStorage.removeItem(OPERATOR_NAME_KEY); }catch(e){}
|
|
1249
|
+
updateOperatorBadge();
|
|
1250
|
+
}
|
|
1251
|
+
function loadOperatorName(){
|
|
1252
|
+
try{ _operatorName = (localStorage.getItem(OPERATOR_NAME_KEY) || '').trim(); }catch(e){ _operatorName = ''; }
|
|
1253
|
+
updateOperatorBadge();
|
|
1254
|
+
return _operatorName;
|
|
1255
|
+
}
|
|
1256
|
+
function operatorHeaders(){
|
|
1257
|
+
var n = getOperatorName();
|
|
1258
|
+
return n ? {'X-Operator': n} : {};
|
|
1259
|
+
}
|
|
1260
|
+
function updateOperatorBadge(){
|
|
1261
|
+
var el = $('rp-operator-badge');
|
|
1262
|
+
if(!el) return;
|
|
1263
|
+
var n = getOperatorName();
|
|
1264
|
+
el.textContent = n ? n : 'set operator';
|
|
1265
|
+
el.classList.toggle('set', !n);
|
|
1266
|
+
el.title = n ? ('Operator: '+n+' — click to change') : 'Click to set your operator name (used in the audit log)';
|
|
1267
|
+
}
|
|
1268
|
+
function openOperatorModal(){
|
|
1269
|
+
var cur = getOperatorName();
|
|
1270
|
+
var html = ''
|
|
1271
|
+
+ '<div class="rp-field"><label>Your name (operator)</label>'
|
|
1272
|
+
+ '<input id="rp-operator-input" maxlength="64" value="'+esc(cur)+'" placeholder="e.g. Jane Operator" autocomplete="off">'
|
|
1273
|
+
+ '</div>'
|
|
1274
|
+
+ '<div class="rp-field"><label style="font-weight:400;color:var(--muted)">'
|
|
1275
|
+
+ 'This name is sent to the scheduler as the <code>X-Operator</code> header on every action you take (End session, Restart, Set production mode, etc.) '
|
|
1276
|
+
+ 'and recorded in the audit log. Stored only in your browser\'s localStorage — not on the server. '
|
|
1277
|
+
+ 'Use a real name so postmortems can answer "who clicked that?".'
|
|
1278
|
+
+ '</label></div>';
|
|
1279
|
+
showModal('Operator identity', html);
|
|
1280
|
+
setTimeout(function(){
|
|
1281
|
+
var inp = $('rp-operator-input');
|
|
1282
|
+
if(inp){ try{ inp.focus(); inp.select(); }catch(e){} }
|
|
1283
|
+
}, 30);
|
|
1284
|
+
}
|
|
1285
|
+
// First-load prompt: shown if the operator hasn't set a name yet.
|
|
1286
|
+
// They can dismiss (no name → actions still work, audit shows
|
|
1287
|
+
// "operator") or set one.
|
|
1288
|
+
function maybePromptOperator(){
|
|
1289
|
+
if(getOperatorName()) return;
|
|
1290
|
+
setTimeout(openOperatorModal, 800);
|
|
1291
|
+
}
|
|
1292
|
+
// click-to-edit from the top bar; the top bar element id is
|
|
1293
|
+
// "rp-operator-badge" — wired up near the end of the IIFE.
|
|
1294
|
+
document.addEventListener('click', function(e){
|
|
1295
|
+
var b = e.target.closest && e.target.closest('#rp-operator-badge');
|
|
1296
|
+
if(b){ e.preventDefault(); openOperatorModal(); }
|
|
1297
|
+
});
|
|
1298
|
+
// Save on Enter / Save-button in the modal
|
|
1299
|
+
document.addEventListener('keydown', function(e){
|
|
1300
|
+
if(e.target && e.target.id === 'rp-operator-input' && e.key === 'Enter'){
|
|
1301
|
+
e.preventDefault();
|
|
1302
|
+
var v = e.target.value;
|
|
1303
|
+
setOperatorName(v);
|
|
1304
|
+
closeModal();
|
|
1305
|
+
showOut(v ? ('Operator set to: '+v+' — your actions will be attributed to '+v+' in the audit log')
|
|
1306
|
+
: 'Operator name cleared — actions will be attributed to "operator"');
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1087
1310
|
function getJson(path){
|
|
1088
1311
|
// QS is "?token=..."; a path that already carries its own query string
|
|
1089
1312
|
// (e.g. "/robopark/api/sessions?active_only=false&limit=200") needs "&"
|
|
1090
1313
|
// instead, or the token param lands inside an invalid second "?" and
|
|
1091
1314
|
// the request goes out unauthenticated -> 401.
|
|
1092
1315
|
var url = path + (path.indexOf('?')>=0 ? QS.replace('?','&') : QS);
|
|
1093
|
-
|
|
1316
|
+
var headers = operatorHeaders();
|
|
1317
|
+
return fetch(url, {cache:'no-store', headers: headers}).then(function(r){
|
|
1094
1318
|
if(!r.ok) throw new Error(path+' '+r.status);
|
|
1095
1319
|
return r.json();
|
|
1096
1320
|
});
|
|
@@ -1139,17 +1363,23 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1139
1363
|
return r.json();
|
|
1140
1364
|
}).then(function(s){
|
|
1141
1365
|
return mergeSchedulerRobots(s.nodes||[]).then(function(nodes){
|
|
1142
|
-
|
|
1143
|
-
|
|
1366
|
+
s.nodes = nodes;
|
|
1367
|
+
everOk = true; setLive(true); lastStatus = s; lastFedStatusAt = Date.now();
|
|
1144
1368
|
if(!selfId && s.self) selfId = s.self.nodeId;
|
|
1145
1369
|
if(s.control){ rpReadOnly = !!s.control.readonly; applyReadOnly(); }
|
|
1146
1370
|
updateHw(s);
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1371
|
+
renderSummary(s);
|
|
1372
|
+
renderFleet(s.nodes||[]);
|
|
1373
|
+
renderHw(s.hardware);
|
|
1374
|
+
renderFeed(s.activity||[]);
|
|
1375
|
+
Park.onStatus(s);
|
|
1376
|
+
// C4: keep the incidents sub-tab's KPI + count fresh even when
|
|
1377
|
+
// the user isn't actively on it. The full incident list is
|
|
1378
|
+
// re-rendered only when the tab is open (cheap, since the
|
|
1379
|
+
// active-poll already pulls /api/telemetry at 3s).
|
|
1380
|
+
if(typeof renderLivenessKpis === 'function') renderLivenessKpis();
|
|
1381
|
+
if(rpSubTab === 'incidents' && typeof renderIncidents === 'function') renderIncidents();
|
|
1382
|
+
});
|
|
1153
1383
|
}).catch(function(){ setLive(false); });
|
|
1154
1384
|
}
|
|
1155
1385
|
|
|
@@ -1235,9 +1465,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1235
1465
|
|
|
1236
1466
|
function rpAction(method, path, body){
|
|
1237
1467
|
if(rpReadOnly){ showOut('This node is read-only — restart the hub with --token to enable control actions.'); return Promise.resolve(null); }
|
|
1468
|
+
var headers = Object.assign({}, body?{'content-type':'application/json'}:{}, operatorHeaders());
|
|
1238
1469
|
return fetch(path+QS, {
|
|
1239
1470
|
method: method, cache:'no-store',
|
|
1240
|
-
headers:
|
|
1471
|
+
headers: headers,
|
|
1241
1472
|
body: body?JSON.stringify(body):undefined
|
|
1242
1473
|
}).then(function(r){
|
|
1243
1474
|
if(r.status===403){ return r.json().then(function(j){ if(j&&j.readonly){ rpReadOnly=true; applyReadOnly(); } throw new Error(j&&j.error||'forbidden'); }); }
|
|
@@ -1769,11 +2000,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1769
2000
|
loadCharacterPresets();
|
|
1770
2001
|
loadVoiceStacks();
|
|
1771
2002
|
loadParkSlots();
|
|
1772
|
-
fetch('/robopark/api/telemetry'+QS, {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
|
|
2003
|
+
fetch('/robopark/api/telemetry'+QS, {cache:'no-store', headers: operatorHeaders()}).then(function(r){ return r.json(); }).then(function(d){
|
|
2004
|
+
lastSchedulerPollAt = Date.now();
|
|
1773
2005
|
var grid = $('rp-grid'), totals = $('rp-totals');
|
|
1774
2006
|
// stash per-robot rows so the Park drawer can show live session stats
|
|
1775
2007
|
roboparkByName = {};
|
|
1776
2008
|
robotsFrom(d).forEach(function(x){ roboparkByName[robotName(x)] = x; if(x.name) roboparkByName[x.name] = x; });
|
|
2009
|
+
// C4: also feed lastRobots for the Incidents sub-tab
|
|
2010
|
+
lastRobots = robotsFrom(d) || [];
|
|
1777
2011
|
// push the same map into the Park so the canvas can color robots
|
|
1778
2012
|
// by live/talking/waiting/ended status. updateTelemetry also
|
|
1779
2013
|
// re-derives the readiness → status mapping for every node.
|
|
@@ -1908,12 +2142,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1908
2142
|
s.classList.toggle('active', s.id==='rp-sub-'+name);
|
|
1909
2143
|
}
|
|
1910
2144
|
// stop every sub-tab interval, then start only the active one
|
|
1911
|
-
var names = ['overview','live','history','audit','fleet','setup'];
|
|
2145
|
+
var names = ['overview','incidents','live','history','audit','fleet','setup'];
|
|
1912
2146
|
for(var k=0;k<names.length;k++){
|
|
1913
2147
|
var n=names[k];
|
|
1914
2148
|
if(rpIntervals[n]){ clearInterval(rpIntervals[n]); rpIntervals[n]=null; }
|
|
1915
2149
|
}
|
|
1916
2150
|
if(name==='overview'){ pollOverview(); rpIntervals.overview = setInterval(pollOverview, 4000); }
|
|
2151
|
+
if(name==='incidents'){ pollIncidents(); rpIntervals.incidents = setInterval(pollIncidents, 4000); }
|
|
1917
2152
|
if(name==='live'){ pollLiveSessions(); startLiveAutoRefresh(); }
|
|
1918
2153
|
if(name==='history'){ rpHistoryPage=0; pollHistory(); }
|
|
1919
2154
|
if(name==='audit'){ pollAuditLog(); rpIntervals.audit = setInterval(pollAuditLog, 6000); }
|
|
@@ -1933,7 +2168,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1933
2168
|
var h=String(location.hash||'').replace('#','');
|
|
1934
2169
|
var m = h.match(/^robopark\\/(.+)$/);
|
|
1935
2170
|
var initial = (m && m[1]) ? m[1] : 'overview';
|
|
1936
|
-
if(['overview','live','history','audit','fleet','setup'].indexOf(initial)<0) initial='overview';
|
|
2171
|
+
if(['overview','incidents','live','history','audit','fleet','setup'].indexOf(initial)<0) initial='overview';
|
|
1937
2172
|
setRpSubTab(initial);
|
|
1938
2173
|
}
|
|
1939
2174
|
|
|
@@ -2363,10 +2598,16 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2363
2598
|
})();
|
|
2364
2599
|
|
|
2365
2600
|
// ── Audit Log sub-tab ──
|
|
2366
|
-
var rpAuditFilter = { category: '' };
|
|
2601
|
+
var rpAuditFilter = { category: '', operator: '' };
|
|
2367
2602
|
var rpAuditLast = [];
|
|
2368
2603
|
function pollAuditLog(){
|
|
2369
|
-
var
|
|
2604
|
+
var parts = [];
|
|
2605
|
+
if(rpAuditFilter.category) parts.push('category='+encodeURIComponent(rpAuditFilter.category));
|
|
2606
|
+
parts.push('limit=200');
|
|
2607
|
+
// Note: we filter by operator client-side (the /api/history endpoint
|
|
2608
|
+
// doesn't take an actor= filter). The filter chip toggles a
|
|
2609
|
+
// post-fetch predicate in renderAuditLog.
|
|
2610
|
+
var qs = '?' + parts.join('&');
|
|
2370
2611
|
getJson('/robopark/api/history'+qs).then(function(rows){
|
|
2371
2612
|
renderAuditLog(Array.isArray(rows)?rows:[]);
|
|
2372
2613
|
}).catch(function(){
|
|
@@ -2375,11 +2616,33 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2375
2616
|
});
|
|
2376
2617
|
}
|
|
2377
2618
|
function renderAuditLog(rows){
|
|
2619
|
+
// Apply the "by me" filter (client-side; the API doesn't take an
|
|
2620
|
+
// actor= filter today). The chip toggles rpAuditFilter.operator.
|
|
2621
|
+
var myName = (getOperatorName()||'').toLowerCase();
|
|
2622
|
+
var meOnly = !!rpAuditFilter.operator;
|
|
2623
|
+
if(meOnly && myName){
|
|
2624
|
+
rows = rows.filter(function(r){ return String(r.actor||'').toLowerCase() === myName; });
|
|
2625
|
+
}
|
|
2626
|
+
// keep the chip label in sync with the current operator
|
|
2627
|
+
var chip = $('rp-audit-byme');
|
|
2628
|
+
if(chip){
|
|
2629
|
+
if(!myName){
|
|
2630
|
+
chip.classList.add('disabled');
|
|
2631
|
+
chip.title = 'Set your operator name (click the badge in the top bar) to use this filter';
|
|
2632
|
+
} else {
|
|
2633
|
+
chip.classList.remove('disabled');
|
|
2634
|
+
chip.title = 'Showing only actions attributed to "'+myName+'"';
|
|
2635
|
+
chip.textContent = '👤 by me ('+myName+')';
|
|
2636
|
+
}
|
|
2637
|
+
chip.classList.toggle('active', meOnly);
|
|
2638
|
+
}
|
|
2378
2639
|
var tbody=$('rp-audit-tbody'), empty=$('rp-audit-empty'), wrap=$('rp-audit-wrap');
|
|
2379
2640
|
if(!rows.length){
|
|
2380
2641
|
tbody.innerHTML='';
|
|
2381
2642
|
wrap.style.display='none';
|
|
2382
2643
|
empty.style.display='block';
|
|
2644
|
+
if(meOnly) empty.textContent = 'no audit events attributed to '+myName+' in this window';
|
|
2645
|
+
else empty.textContent = 'no audit events yet';
|
|
2383
2646
|
return;
|
|
2384
2647
|
}
|
|
2385
2648
|
empty.style.display='none';
|
|
@@ -2392,17 +2655,24 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2392
2655
|
var act = ev.action || '—';
|
|
2393
2656
|
var ent = (ev.entity_type||'')+(ev.entity_id?(' · '+ev.entity_id):'');
|
|
2394
2657
|
var det = ev.details || '';
|
|
2395
|
-
var
|
|
2658
|
+
var actor = ev.actor || '—';
|
|
2396
2659
|
if(typeof det==='object') det = JSON.stringify(det);
|
|
2397
2660
|
var detShort = String(det);
|
|
2398
2661
|
if(detShort.length>80) detShort = detShort.slice(0,77)+'…';
|
|
2399
|
-
|
|
2662
|
+
// Highlight rows attributed to the current operator so they pop
|
|
2663
|
+
var isMine = myName && String(actor).toLowerCase() === myName;
|
|
2664
|
+
var actorCellCls = isMine ? 'rp-actor-mine' : 'rp-actor-other';
|
|
2665
|
+
var rowCls = isMine ? 'rp-audit-row-mine' : '';
|
|
2666
|
+
html += '<tr class="'+rowCls+'" data-idx="'+i+'">'
|
|
2400
2667
|
+ '<td><span class="t-rel">'+esc(rpFmtRelative(ts))+'</span><span class="t-abs">'+esc(rpFmtAbs(ts))+'</span></td>'
|
|
2401
2668
|
+ '<td><span class="rp-end-badge">'+esc(rpCategoryIcon(cat))+' '+esc(cat)+'</span></td>'
|
|
2402
2669
|
+ '<td><b>'+esc(act)+'</b></td>'
|
|
2403
2670
|
+ '<td>'+esc(ent||'—')+'</td>'
|
|
2404
2671
|
+ '<td style="max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="'+esc(String(det).replace(/"/g,'"'))+'">'+esc(detShort)+'</td>'
|
|
2405
|
-
+ '<td
|
|
2672
|
+
+ '<td class="'+actorCellCls+'" title="'+esc(actor)+'">'
|
|
2673
|
+
+ (isMine ? '👤 ' : '')
|
|
2674
|
+
+ '<b>'+esc(actor)+'</b>'
|
|
2675
|
+
+ '</td>'
|
|
2406
2676
|
+ '</tr>';
|
|
2407
2677
|
}
|
|
2408
2678
|
tbody.innerHTML = html;
|
|
@@ -2438,6 +2708,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2438
2708
|
})(chips[i]);
|
|
2439
2709
|
}
|
|
2440
2710
|
var rb=$('rp-audit-refresh'); if(rb) rb.addEventListener('click', pollAuditLog);
|
|
2711
|
+
var byme=$('rp-audit-byme'); if(byme) byme.addEventListener('click', function(){
|
|
2712
|
+
if(!getOperatorName()){ openOperatorModal(); return; }
|
|
2713
|
+
rpAuditFilter.operator = rpAuditFilter.operator ? '' : getOperatorName();
|
|
2714
|
+
pollAuditLog();
|
|
2715
|
+
});
|
|
2441
2716
|
var tb=$('rp-audit-tbody'); if(tb) tb.addEventListener('click', function(e){
|
|
2442
2717
|
var tr = e.target.closest ? e.target.closest('tr') : null; if(!tr) return;
|
|
2443
2718
|
var idx = parseInt(tr.getAttribute('data-idx'),10);
|
|
@@ -2445,6 +2720,291 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
2445
2720
|
});
|
|
2446
2721
|
})();
|
|
2447
2722
|
|
|
2723
|
+
// ── C4: Incidents sub-tab ──
|
|
2724
|
+
// Surfaces every active incident (any robot whose readiness_code is
|
|
2725
|
+
// in a non-ready set) and lets an operator acknowledge each one.
|
|
2726
|
+
// Acknowledgements live in localStorage, scoped per browser, so
|
|
2727
|
+
// multiple operators on different machines each keep their own
|
|
2728
|
+
// "in-progress" list — but every Ack also writes an audit event
|
|
2729
|
+
// through the operator-actor middleware (C3), so the action shows
|
|
2730
|
+
// up in the central history log under the operator's name.
|
|
2731
|
+
//
|
|
2732
|
+
// Severity map per readiness_code:
|
|
2733
|
+
// stuck_session | stale_session | join_timeout | device_missing |
|
|
2734
|
+
// server_unavailable | heartbeat_stale → critical (bad)
|
|
2735
|
+
// production_off | robot_production_off | production mode off → warning
|
|
2736
|
+
// not_joined | unknown | anything else that isn't 'ready' / 'in_session' → info
|
|
2737
|
+
var INCIDENT_SEVERITY = {
|
|
2738
|
+
stuck_session: 'bad',
|
|
2739
|
+
stale_session: 'bad',
|
|
2740
|
+
join_timeout: 'bad',
|
|
2741
|
+
device_missing: 'bad',
|
|
2742
|
+
server_unavailable: 'bad',
|
|
2743
|
+
heartbeat_stale: 'bad',
|
|
2744
|
+
production_off: 'warn',
|
|
2745
|
+
robot_production_off:'warn',
|
|
2746
|
+
not_joined: 'info',
|
|
2747
|
+
unknown: 'info',
|
|
2748
|
+
};
|
|
2749
|
+
var INCIDENT_BAD_CODES = ['stuck_session','stale_session','join_timeout','device_missing','server_unavailable','heartbeat_stale'];
|
|
2750
|
+
var INCIDENT_ACK_KEY = 'rp-incident-acks-v1';
|
|
2751
|
+
// Active incidents: derived live from the most recent telemetry
|
|
2752
|
+
// snapshot in lastRobots. Acknowledged list is in localStorage,
|
|
2753
|
+
// keyed by robotId|readiness so the same robot entering
|
|
2754
|
+
// the same state again reopens the incident.
|
|
2755
|
+
var _incidentSevFilter = '';
|
|
2756
|
+
var _incidentAcks = {}; // key -> {t, by}
|
|
2757
|
+
function _loadIncidentAcks(){
|
|
2758
|
+
try{
|
|
2759
|
+
var raw = localStorage.getItem(INCIDENT_ACK_KEY);
|
|
2760
|
+
_incidentAcks = raw ? JSON.parse(raw) : {};
|
|
2761
|
+
}catch(e){ _incidentAcks = {}; }
|
|
2762
|
+
}
|
|
2763
|
+
function _saveIncidentAcks(){
|
|
2764
|
+
try{ localStorage.setItem(INCIDENT_ACK_KEY, JSON.stringify(_incidentAcks)); }catch(e){}
|
|
2765
|
+
}
|
|
2766
|
+
function _incidentKey(robotId, code){ return robotId + '|' + code; }
|
|
2767
|
+
function _ackIncident(robotId, code, readinessMessage){
|
|
2768
|
+
var key = _incidentKey(robotId, code);
|
|
2769
|
+
_incidentAcks[key] = { t: Date.now(), by: getOperatorName() || 'operator', message: readinessMessage || '' };
|
|
2770
|
+
_saveIncidentAcks();
|
|
2771
|
+
// Mirror the ack to the audit log so it's visible centrally too.
|
|
2772
|
+
// rpAction carries the X-Operator header (C3), so the audit row
|
|
2773
|
+
// will be attributed to the current operator.
|
|
2774
|
+
rpAction('POST','/robopark/api/incidents/ack',{ robot_id: robotId, code: code, message: readinessMessage || '' })
|
|
2775
|
+
.catch(function(e){ /* soft-fail; the local ack still stands */ });
|
|
2776
|
+
renderIncidents();
|
|
2777
|
+
}
|
|
2778
|
+
function _unackIncident(robotId, code){
|
|
2779
|
+
var key = _incidentKey(robotId, code);
|
|
2780
|
+
delete _incidentAcks[key];
|
|
2781
|
+
_saveIncidentAcks();
|
|
2782
|
+
renderIncidents();
|
|
2783
|
+
}
|
|
2784
|
+
// Compute the list of currently-active incidents from the last
|
|
2785
|
+
// /api/telemetry snapshot. Includes nodes that aren't even in the
|
|
2786
|
+
// fleet (id starts with 'pad:') — we still want to know if a slot
|
|
2787
|
+
// is empty for an extended period.
|
|
2788
|
+
function _collectActiveIncidents(){
|
|
2789
|
+
var list = [];
|
|
2790
|
+
var seen = {};
|
|
2791
|
+
for(var i=0;i<(lastRobots||[]).length;i++){
|
|
2792
|
+
var r = lastRobots[i];
|
|
2793
|
+
var code = r.readiness_code || (r.connected ? 'ready' : 'not_joined');
|
|
2794
|
+
if(!code || code === 'ready' || code === 'in_session') continue;
|
|
2795
|
+
var sev = INCIDENT_SEVERITY[code] || 'info';
|
|
2796
|
+
if(sev === 'info' && code === 'not_joined') continue; // too noisy; skip
|
|
2797
|
+
var key = _incidentKey(r.id, code);
|
|
2798
|
+
seen[key] = true;
|
|
2799
|
+
// find first-seen: persisted across reloads via a parallel map
|
|
2800
|
+
var firstSeenKey = 'rp-incident-first-seen-v1';
|
|
2801
|
+
var firstSeen = {};
|
|
2802
|
+
try{ firstSeen = JSON.parse(localStorage.getItem(firstSeenKey) || '{}'); }catch(e){}
|
|
2803
|
+
var fs = firstSeen[key] || Date.now();
|
|
2804
|
+
if(!firstSeen[key]){ firstSeen[key] = Date.now(); try{ localStorage.setItem(firstSeenKey, JSON.stringify(firstSeen)); }catch(e){} }
|
|
2805
|
+
list.push({
|
|
2806
|
+
robotId: r.id,
|
|
2807
|
+
robotName: r.name || r.id,
|
|
2808
|
+
code: code,
|
|
2809
|
+
severity: sev,
|
|
2810
|
+
message: r.readiness_message || code,
|
|
2811
|
+
firstSeen: fs,
|
|
2812
|
+
ack: _incidentAcks[key] || null,
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
// prune stale firstSeen entries so localStorage doesn't grow forever
|
|
2816
|
+
try{
|
|
2817
|
+
var fsAll = JSON.parse(localStorage.getItem('rp-incident-first-seen-v1') || '{}');
|
|
2818
|
+
var keys = Object.keys(fsAll);
|
|
2819
|
+
var pruned = {};
|
|
2820
|
+
for(var k=0;k<keys.length;k++){ if(seen[keys[k]]) pruned[keys[k]] = fsAll[keys[k]]; }
|
|
2821
|
+
localStorage.setItem('rp-incident-first-seen-v1', JSON.stringify(pruned));
|
|
2822
|
+
}catch(e){}
|
|
2823
|
+
return list;
|
|
2824
|
+
}
|
|
2825
|
+
function renderIncidents(){
|
|
2826
|
+
var incidents = _collectActiveIncidents();
|
|
2827
|
+
// severity filter
|
|
2828
|
+
if(_incidentSevFilter){
|
|
2829
|
+
incidents = incidents.filter(function(i){ return i.severity === _incidentSevFilter; });
|
|
2830
|
+
}
|
|
2831
|
+
// sort: bad → warn → info, then by firstSeen desc
|
|
2832
|
+
var sevOrder = { bad: 0, warn: 1, info: 2 };
|
|
2833
|
+
incidents.sort(function(a,b){
|
|
2834
|
+
var so = (sevOrder[a.severity]||9) - (sevOrder[b.severity]||9);
|
|
2835
|
+
if(so !== 0) return so;
|
|
2836
|
+
return b.firstSeen - a.firstSeen;
|
|
2837
|
+
});
|
|
2838
|
+
var tbody = $('rp-inc-tbody'), empty = $('rp-inc-empty'), wrap = $('rp-inc-wrap');
|
|
2839
|
+
var badge = $('rps-incidents-badge');
|
|
2840
|
+
var total = _collectActiveIncidents().length;
|
|
2841
|
+
if(badge) badge.textContent = total;
|
|
2842
|
+
if(!incidents.length){
|
|
2843
|
+
if(tbody) tbody.innerHTML = '';
|
|
2844
|
+
if(wrap) wrap.style.display='none';
|
|
2845
|
+
if(empty){ empty.style.display='block'; empty.textContent = total ? 'no incidents match this filter' : 'no active incidents — every robot is ready'; }
|
|
2846
|
+
} else {
|
|
2847
|
+
if(empty) empty.style.display='none';
|
|
2848
|
+
if(wrap) wrap.style.display='block';
|
|
2849
|
+
var html = '';
|
|
2850
|
+
for(var i=0;i<incidents.length;i++){
|
|
2851
|
+
var inc = incidents[i];
|
|
2852
|
+
var sevClass = inc.severity === 'bad' ? 'rp-inc-sev-bad' : (inc.severity === 'warn' ? 'rp-inc-sev-warn' : 'rp-inc-sev-info');
|
|
2853
|
+
var sevMark = inc.severity === 'bad' ? '✗' : (inc.severity === 'warn' ? '⚠' : '·');
|
|
2854
|
+
var dur = fmtDur(Date.now() - inc.firstSeen);
|
|
2855
|
+
var fsRel = rpFmtRelative(inc.firstSeen);
|
|
2856
|
+
var fsAbs = rpFmtAbs(inc.firstSeen);
|
|
2857
|
+
var ackTag = inc.ack ? '<span class="rp-inc-acked-tag" title="acked by '+esc(inc.ack.by)+' at '+rpFmtAbs(inc.ack.t)+'">✓ acked</span>' : '';
|
|
2858
|
+
html += '<tr class="'+sevClass+'">'
|
|
2859
|
+
+ '<td><span class="rp-inc-sev">'+sevMark+' '+esc(inc.severity)+'</span></td>'
|
|
2860
|
+
+ '<td><b>'+esc(inc.robotName)+'</b> <span class="rp-inc-id">'+esc(inc.robotId)+'</span></td>'
|
|
2861
|
+
+ '<td><span class="mono">'+esc(inc.code)+'</span></td>'
|
|
2862
|
+
+ '<td><span class="t-rel">'+esc(fsRel)+'</span><span class="t-abs">'+esc(fsAbs)+'</span></td>'
|
|
2863
|
+
+ '<td>'+esc(dur)+'</td>'
|
|
2864
|
+
+ '<td>'+esc(inc.message)+' '+ackTag+'</td>'
|
|
2865
|
+
+ '<td style="white-space:nowrap;">';
|
|
2866
|
+
if(!inc.ack){
|
|
2867
|
+
html += '<button class="rp-btn" data-inc-act="ack" data-robot="'+esc(inc.robotId)+'" data-code="'+esc(inc.code)+'">Acknowledge</button>';
|
|
2868
|
+
} else {
|
|
2869
|
+
html += '<button class="rp-btn ghost" data-inc-act="unack" data-robot="'+esc(inc.robotId)+'" data-code="'+esc(inc.code)+'" title="'+esc('Acked by '+inc.ack.by+' at '+rpFmtAbs(inc.ack.t))+'">undo</button>';
|
|
2870
|
+
}
|
|
2871
|
+
html += '</td></tr>';
|
|
2872
|
+
}
|
|
2873
|
+
tbody.innerHTML = html;
|
|
2874
|
+
}
|
|
2875
|
+
// also render the acknowledged list
|
|
2876
|
+
renderIncidentsAcknowledged();
|
|
2877
|
+
}
|
|
2878
|
+
function renderIncidentsAcknowledged(){
|
|
2879
|
+
var acks = Object.keys(_incidentAcks).map(function(k){
|
|
2880
|
+
var parts = k.split('|');
|
|
2881
|
+
return { key: k, robotId: parts[0], code: parts.slice(1).join('|'), t: _incidentAcks[k].t, by: _incidentAcks[k].by, message: _incidentAcks[k].message };
|
|
2882
|
+
});
|
|
2883
|
+
// drop acks whose underlying incident has cleared
|
|
2884
|
+
var stillActive = {};
|
|
2885
|
+
var active = _collectActiveIncidents();
|
|
2886
|
+
for(var i=0;i<active.length;i++){ stillActive[active[i].robotId+'|'+active[i].code] = true; }
|
|
2887
|
+
acks = acks.filter(function(a){ return !stillActive[a.key]; });
|
|
2888
|
+
// sort newest-first
|
|
2889
|
+
acks.sort(function(a,b){ return b.t - a.t; });
|
|
2890
|
+
var tbody = $('rp-inc-ack-tbody'), empty = $('rp-inc-ack-empty'), wrap = $('rp-inc-ack-wrap');
|
|
2891
|
+
var counter = $('rp-inc-ack-count');
|
|
2892
|
+
if(counter) counter.textContent = acks.length;
|
|
2893
|
+
if(!acks.length){
|
|
2894
|
+
if(tbody) tbody.innerHTML = '';
|
|
2895
|
+
if(wrap) wrap.style.display='none';
|
|
2896
|
+
if(empty) empty.style.display='block';
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
if(empty) empty.style.display='none';
|
|
2900
|
+
if(wrap) wrap.style.display='block';
|
|
2901
|
+
var html = '';
|
|
2902
|
+
for(var i=0;i<acks.length;i++){
|
|
2903
|
+
var a = acks[i];
|
|
2904
|
+
html += '<tr class="rp-inc-acked-row">'
|
|
2905
|
+
+ '<td><span class="t-rel">'+esc(rpFmtRelative(a.t))+'</span><span class="t-abs">'+esc(rpFmtAbs(a.t))+'</span></td>'
|
|
2906
|
+
+ '<td><b>'+esc(a.by)+'</b></td>'
|
|
2907
|
+
+ '<td>'+esc(a.robotId)+'</td>'
|
|
2908
|
+
+ '<td><span class="mono">'+esc(a.code)+'</span></td>'
|
|
2909
|
+
+ '<td>'+esc(a.message||'')+'</td>'
|
|
2910
|
+
+ '<td style="white-space:nowrap;"><button class="rp-btn ghost" data-inc-act="unack" data-robot="'+esc(a.robotId)+'" data-code="'+esc(a.code)+'">remove</button></td>'
|
|
2911
|
+
+ '</tr>';
|
|
2912
|
+
}
|
|
2913
|
+
tbody.innerHTML = html;
|
|
2914
|
+
}
|
|
2915
|
+
function fmtDur(ms){
|
|
2916
|
+
if(ms < 0) ms = 0;
|
|
2917
|
+
var s = Math.floor(ms/1000);
|
|
2918
|
+
if(s < 60) return s+'s';
|
|
2919
|
+
var m = Math.floor(s/60);
|
|
2920
|
+
if(m < 60) return m+'m '+(s%60)+'s';
|
|
2921
|
+
var h = Math.floor(m/60);
|
|
2922
|
+
if(h < 24) return h+'h '+(m%60)+'m';
|
|
2923
|
+
var d = Math.floor(h/24);
|
|
2924
|
+
return d+'d '+(h%24)+'h';
|
|
2925
|
+
}
|
|
2926
|
+
var lastRobots = [];
|
|
2927
|
+
function pollIncidents(){
|
|
2928
|
+
// KPI tiles: per-source liveness.
|
|
2929
|
+
renderLivenessKpis();
|
|
2930
|
+
// Reuse the most recent telemetry snapshot; if the user is on
|
|
2931
|
+
// the incidents tab, also kick a fresh fetch.
|
|
2932
|
+
if(rpSubTab === 'incidents'){
|
|
2933
|
+
fetch('/robopark/api/telemetry'+QS, {cache:'no-store', headers: operatorHeaders()}).then(function(r){ return r.json(); }).then(function(d){
|
|
2934
|
+
lastRobots = robotsFrom(d) || [];
|
|
2935
|
+
renderIncidents();
|
|
2936
|
+
}).catch(function(){ renderIncidents(); });
|
|
2937
|
+
} else {
|
|
2938
|
+
renderIncidents();
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
function renderLivenessKpis(){
|
|
2942
|
+
var now = Date.now();
|
|
2943
|
+
// 1. mesh (/fed/status) — lastStatus is set by /fed/status poll;
|
|
2944
|
+
// poll() writes to lastStatus whenever the fetch resolves.
|
|
2945
|
+
var mesh = (typeof lastStatus === 'object' && lastStatus) ? lastStatus : null;
|
|
2946
|
+
var meshT = mesh ? (lastFedStatusAt || null) : null;
|
|
2947
|
+
paintKpi('mesh', !!mesh, meshT, meshT ? fmtDur(now - meshT) + ' ago' : 'no successful poll', meshT ? (now - meshT < 15000 ? 'ok' : (now - meshT < 30000 ? 'warn' : 'bad')) : 'bad');
|
|
2948
|
+
// 2. scheduler (/robopark/api/telemetry) — track on a successful fetch
|
|
2949
|
+
var schedT = lastSchedulerPollAt || null;
|
|
2950
|
+
paintKpi('sched', !!schedT, schedT, schedT ? fmtDur(now - schedT) + ' ago' : 'no successful poll', schedT ? (now - schedT < 8000 ? 'ok' : (now - schedT < 20000 ? 'warn' : 'bad')) : 'bad');
|
|
2951
|
+
// 3. cameras — at least one camera source is "ok" across the fleet
|
|
2952
|
+
var camOk = 0, camTotal = 0;
|
|
2953
|
+
for(var i=0;i<lastRobots.length;i++){ var r=lastRobots[i]; if(r.id && r.id.indexOf('pad:')!==0){ camTotal++; if(r.connected && r.current_session_id) camOk++; } }
|
|
2954
|
+
var camDot = camTotal === 0 ? 'warn' : (camOk > 0 ? 'ok' : 'warn');
|
|
2955
|
+
paintKpi('cam', camTotal>0, camTotal, camOk + ' of ' + camTotal + ' have a live session', camDot);
|
|
2956
|
+
// 4. production mode — read the global + per-device effective flag
|
|
2957
|
+
var pmOn = (typeof settings !== 'undefined' && settings && settings.production_mode === true);
|
|
2958
|
+
var pmDot = pmOn ? 'ok' : 'warn';
|
|
2959
|
+
paintKpi('prod', pmOn, pmOn ? 'on' : 'off', pmOn ? 'global switch on' : 'global switch OFF — incidents will be informational only', pmDot);
|
|
2960
|
+
}
|
|
2961
|
+
function paintKpi(name, ok, value, meta, severity){
|
|
2962
|
+
var v = $('rp-inc-kpi-'+name+'-v');
|
|
2963
|
+
var m = $('rp-inc-kpi-'+name+'-meta');
|
|
2964
|
+
var d = $('rp-inc-kpi-'+name+'-dot');
|
|
2965
|
+
if(v) v.textContent = (value === null || value === undefined) ? '—' : String(value);
|
|
2966
|
+
if(m) m.textContent = meta || '';
|
|
2967
|
+
if(d){
|
|
2968
|
+
d.className = 'pk-kpi-dot '+(severity === 'ok' ? 'ok' : severity === 'warn' ? 'warn' : severity === 'bad' ? 'bad' : '');
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
// hook the acknowledge / undo buttons (delegated, idempotent)
|
|
2972
|
+
document.addEventListener('click', function(e){
|
|
2973
|
+
var b = e.target.closest && e.target.closest('[data-inc-act]');
|
|
2974
|
+
if(!b) return;
|
|
2975
|
+
if(rpReadOnly){ showOut('read-only node'); return; }
|
|
2976
|
+
var act = b.getAttribute('data-inc-act');
|
|
2977
|
+
var rid = b.getAttribute('data-robot');
|
|
2978
|
+
var code = b.getAttribute('data-code');
|
|
2979
|
+
if(!rid || !code) return;
|
|
2980
|
+
if(act === 'ack'){
|
|
2981
|
+
// find the message from the current incidents list
|
|
2982
|
+
var msg = '';
|
|
2983
|
+
var list = _collectActiveIncidents();
|
|
2984
|
+
for(var i=0;i<list.length;i++){ if(list[i].robotId === rid && list[i].code === code){ msg = list[i].message; break; } }
|
|
2985
|
+
_ackIncident(rid, code, msg);
|
|
2986
|
+
} else if(act === 'unack'){
|
|
2987
|
+
_unackIncident(rid, code);
|
|
2988
|
+
}
|
|
2989
|
+
});
|
|
2990
|
+
// severity-filter chips
|
|
2991
|
+
(function(){
|
|
2992
|
+
var chips = document.querySelectorAll('[data-inc-sev]');
|
|
2993
|
+
for(var i=0;i<chips.length;i++){
|
|
2994
|
+
(function(c){
|
|
2995
|
+
c.addEventListener('click', function(){
|
|
2996
|
+
for(var j=0;j<chips.length;j++) chips[j].classList.remove('active');
|
|
2997
|
+
c.classList.add('active');
|
|
2998
|
+
_incidentSevFilter = c.getAttribute('data-inc-sev') || '';
|
|
2999
|
+
renderIncidents();
|
|
3000
|
+
});
|
|
3001
|
+
})(chips[i]);
|
|
3002
|
+
}
|
|
3003
|
+
var rb = $('rp-inc-refresh'); if(rb) rb.addEventListener('click', pollIncidents);
|
|
3004
|
+
})();
|
|
3005
|
+
// Load the ack list from localStorage on first run.
|
|
3006
|
+
_loadIncidentAcks();
|
|
3007
|
+
|
|
2448
3008
|
// ── Fleet sub-tab (uses the existing #rp-grid) ──
|
|
2449
3009
|
// The Fleet sub-tab reuses the existing #rp-grid render in pollRobopark().
|
|
2450
3010
|
// We add sparklines by overlaying on top of the existing cards.
|
|
@@ -4966,6 +5526,272 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
4966
5526
|
} else { age.textContent=''; }
|
|
4967
5527
|
}
|
|
4968
5528
|
}
|
|
5529
|
+
|
|
5530
|
+
// ── C1: Per-robot shell section ──
|
|
5531
|
+
// Lets an operator tail service logs and run a small allowlist of
|
|
5532
|
+
// diagnostic commands (uptime, free -m, ps aux, etc.) on the robot
|
|
5533
|
+
// itself, without leaving the dashboard or opening a terminal. The
|
|
5534
|
+
// round trip is:
|
|
5535
|
+
// 1. dashboard POSTs /robopark/api/robots/{id}/shell/{tail,exec}
|
|
5536
|
+
// 2. scheduler queues a row in shell_requests
|
|
5537
|
+
// 3. robot supervisor's next status-report poll receives the row
|
|
5538
|
+
// in its commands field, runs the request on a background
|
|
5539
|
+
// thread, and POSTs the result to /api/devices/{id}/supervisor-output
|
|
5540
|
+
// 4. dashboard long-polls /robopark/api/robots/{id}/shell/wait/{rid}
|
|
5541
|
+
// with an 8s budget and renders the result.
|
|
5542
|
+
//
|
|
5543
|
+
// The buffer is per-robot, bounded at 5000 lines, persists across
|
|
5544
|
+
// drawer open/close. Service picker is populated from the supervisor
|
|
5545
|
+
// report (the same list shown in the section above).
|
|
5546
|
+
var SHELL_MAX_LINES = 5000;
|
|
5547
|
+
var _shellBuffers = {}; // device_id -> {lines:[{t, kind, text}], lastReqId, lastService}
|
|
5548
|
+
|
|
5549
|
+
function _shellBuf(robotId){
|
|
5550
|
+
if(!_shellBuffers[robotId]) _shellBuffers[robotId] = { lines:[], lastReqId:null, lastService:'' };
|
|
5551
|
+
return _shellBuffers[robotId];
|
|
5552
|
+
}
|
|
5553
|
+
function _shellAppend(robotId, kind, text){
|
|
5554
|
+
var buf = _shellBuf(robotId);
|
|
5555
|
+
var now = Date.now();
|
|
5556
|
+
var lineText = String(text == null ? '' : text);
|
|
5557
|
+
// split on newlines so a multi-line result renders as separate rows
|
|
5558
|
+
var parts = lineText.split(/\r?\n/);
|
|
5559
|
+
for(var i=0;i<parts.length;i++){
|
|
5560
|
+
if(!parts[i] && i===parts.length-1) continue; // skip trailing empty
|
|
5561
|
+
buf.lines.push({ t: now, kind: kind, text: parts[i] });
|
|
5562
|
+
}
|
|
5563
|
+
while(buf.lines.length > SHELL_MAX_LINES) buf.lines.shift();
|
|
5564
|
+
}
|
|
5565
|
+
function _shellStatus(robotId, status, detail){
|
|
5566
|
+
_shellAppend(robotId, 'status', status + (detail ? (' · '+detail) : ''));
|
|
5567
|
+
}
|
|
5568
|
+
function _renderShellBuffer(robotId){
|
|
5569
|
+
var out = $('pk-shell-out'); if(!out) return;
|
|
5570
|
+
var buf = _shellBuf(robotId);
|
|
5571
|
+
var html = '';
|
|
5572
|
+
for(var i=0;i<buf.lines.length;i++){
|
|
5573
|
+
var ln = buf.lines[i];
|
|
5574
|
+
var cls = ln.kind === 'status' ? 'pk-shell-status'
|
|
5575
|
+
: ln.kind === 'stderr' ? 'pk-shell-stderr'
|
|
5576
|
+
: ln.kind === 'header' ? 'pk-shell-header'
|
|
5577
|
+
: ln.kind === 'input' ? 'pk-shell-input'
|
|
5578
|
+
: 'pk-shell-stdout';
|
|
5579
|
+
var hh = (new Date(ln.t)).toTimeString().slice(0,8);
|
|
5580
|
+
html += '<div class="pk-shell-line '+cls+'">'
|
|
5581
|
+
+ '<span class="pk-shell-ts">'+hh+'</span>'
|
|
5582
|
+
+ '<span class="pk-shell-text">'+esc(ln.text)+'</span>'
|
|
5583
|
+
+ '</div>';
|
|
5584
|
+
}
|
|
5585
|
+
out.innerHTML = html;
|
|
5586
|
+
// auto-scroll if the user hasn't scrolled up
|
|
5587
|
+
if(out.__autoscroll !== false){
|
|
5588
|
+
out.scrollTop = out.scrollHeight;
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
5591
|
+
// Build the markup for the Shell section. Pulls the list of
|
|
5592
|
+
// available services from the supervisor_status report (if any),
|
|
5593
|
+
// otherwise shows a placeholder.
|
|
5594
|
+
function buildShellSection(robotId){
|
|
5595
|
+
var device = robotId ? schedulerDeviceByName[robotName({name:robotId, id:robotId})] || null : null;
|
|
5596
|
+
// Try to read the device row by id directly
|
|
5597
|
+
try{
|
|
5598
|
+
for(var k in schedulerDevicesById){
|
|
5599
|
+
if(k === robotId){ device = schedulerDevicesById[k]; break; }
|
|
5600
|
+
}
|
|
5601
|
+
}catch(e){}
|
|
5602
|
+
var services = device && device.supervisor_status;
|
|
5603
|
+
var svcOptions = '';
|
|
5604
|
+
if(services && services.length){
|
|
5605
|
+
for(var i=0;i<services.length;i++){
|
|
5606
|
+
svcOptions += '<option value="'+esc(services[i].name)+'">'+esc(services[i].name)+'</option>';
|
|
5607
|
+
}
|
|
5608
|
+
} else {
|
|
5609
|
+
svcOptions = '<option value="">— services will appear once the supervisor reports —</option>';
|
|
5610
|
+
}
|
|
5611
|
+
return ''
|
|
5612
|
+
+ '<div class="pk-sect" id="pk-shell-sect">'
|
|
5613
|
+
+ '<h4>💻 shell <span class="pk-note" style="font-weight:400;margin-left:0.4rem;">per-robot logs & diagnostics</span></h4>'
|
|
5614
|
+
+ '<div class="pk-shell-row">'
|
|
5615
|
+
+ '<label class="pk-shell-lbl">Service</label>'
|
|
5616
|
+
+ '<select id="pk-shell-svc" class="pk-shell-sel" data-robot="'+esc(robotId||'')+'">'+svcOptions+'</select>'
|
|
5617
|
+
+ '<label class="pk-shell-lbl" style="margin-left:0.5rem;">Lines</label>'
|
|
5618
|
+
+ '<input type="number" id="pk-shell-tail" class="pk-shell-num" value="200" min="10" max="2000" step="10">'
|
|
5619
|
+
+ '<button type="button" class="rp-btn" id="pk-shell-fetch" data-robot="'+esc(robotId||'')+'" style="margin-left:0.4rem;">Tail logs</button>'
|
|
5620
|
+
+ '</div>'
|
|
5621
|
+
+ '<div class="pk-shell-out-wrap">'
|
|
5622
|
+
+ '<div class="pk-shell-out" id="pk-shell-out" tabindex="0" role="log" aria-label="robot shell output"><div class="pk-shell-status">— shell idle —</div></div>'
|
|
5623
|
+
+ '</div>'
|
|
5624
|
+
+ '<div class="pk-shell-row" style="margin-top:0.4rem;">'
|
|
5625
|
+
+ '<input type="text" id="pk-shell-cmd" list="pk-shell-cmd-list" placeholder="uptime, free, df, ps, systemctl status preview_agent, journalctl -u preview_agent, …" autocomplete="off" style="flex:1;min-width:0;">'
|
|
5626
|
+
+ '<datalist id="pk-shell-cmd-list">'
|
|
5627
|
+
+ '<option value="uptime"><option value="free"><option value="df"><option value="ps"><option value="uname"><option value="hostname"><option value="ip"><option value="ss">'
|
|
5628
|
+
+ '<option value="systemctl-status"><option value="journalctl"><option value="ls-logs"><option value="os-release">'
|
|
5629
|
+
+ '</datalist>'
|
|
5630
|
+
+ '<button type="button" class="rp-btn primary" id="pk-shell-run" data-robot="'+esc(robotId||'')+'" style="margin-left:0.4rem;">Run</button>'
|
|
5631
|
+
+ '<button type="button" class="rp-btn" id="pk-shell-autoscroll" data-robot="'+esc(robotId||'')+'" title="Auto-scroll to the newest line" style="margin-left:0.4rem;">⤓ auto</button>'
|
|
5632
|
+
+ '<button type="button" class="rp-btn" id="pk-shell-clear" data-robot="'+esc(robotId||'')+'" style="margin-left:0.4rem;">Clear</button>'
|
|
5633
|
+
+ '</div>'
|
|
5634
|
+
+ '<div class="pk-shell-status" id="pk-shell-stateline" style="margin-top:0.35rem;">idle</div>'
|
|
5635
|
+
+ '<div class="pk-note" id="pk-shell-help" style="margin-top:0.35rem;">'
|
|
5636
|
+
+ 'Run a pre-approved diagnostic on this robot. Output is captured by robot_supervisor.py (8s budget, 64KB cap, allowlisted commands only).'
|
|
5637
|
+
+ '</div>'
|
|
5638
|
+
+ '</div>';
|
|
5639
|
+
}
|
|
5640
|
+
// Wire the shell section's events. Bound once per buildDrawer call,
|
|
5641
|
+
// but the inputs are re-created each time so we re-bind here.
|
|
5642
|
+
function _bindShellSection(n){
|
|
5643
|
+
if(n.kind !== 'robot') return;
|
|
5644
|
+
var robotId = schedId(n);
|
|
5645
|
+
if(!robotId) return;
|
|
5646
|
+
var $out = $('pk-shell-out');
|
|
5647
|
+
if($out && $out.__autoscroll === undefined) $out.__autoscroll = true;
|
|
5648
|
+
// initial render of any previously-captured output for this robot
|
|
5649
|
+
_renderShellBuffer(robotId);
|
|
5650
|
+
var svcSel = $('pk-shell-svc');
|
|
5651
|
+
if(svcSel && !svcSel.value && svcSel.options.length){
|
|
5652
|
+
// pick the first enabled-looking service
|
|
5653
|
+
for(var i=0;i<svcSel.options.length;i++){
|
|
5654
|
+
if(svcSel.options[i].value){ svcSel.selectedIndex = i; break; }
|
|
5655
|
+
}
|
|
5656
|
+
}
|
|
5657
|
+
var fetchBtn = $('pk-shell-fetch');
|
|
5658
|
+
if(fetchBtn) fetchBtn.addEventListener('click', function(){
|
|
5659
|
+
var svc = ($('pk-shell-svc')||{}).value || '';
|
|
5660
|
+
var lines = parseInt(($('pk-shell-tail')||{}).value, 10) || 200;
|
|
5661
|
+
_shellTail(robotId, svc, lines);
|
|
5662
|
+
});
|
|
5663
|
+
var runBtn = $('pk-shell-run');
|
|
5664
|
+
if(runBtn) runBtn.addEventListener('click', function(){
|
|
5665
|
+
var inp = $('pk-shell-cmd');
|
|
5666
|
+
if(!inp) return;
|
|
5667
|
+
var raw = (inp.value || '').trim();
|
|
5668
|
+
if(!raw) return;
|
|
5669
|
+
// parse "name param1=val1 param2=val2" into the params object
|
|
5670
|
+
var parts = raw.split(/\s+/);
|
|
5671
|
+
var name = parts.shift();
|
|
5672
|
+
var params = {};
|
|
5673
|
+
for(var i=0;i<parts.length;i++){
|
|
5674
|
+
var eq = parts[i].indexOf('=');
|
|
5675
|
+
if(eq < 0) continue;
|
|
5676
|
+
params[parts[i].slice(0, eq)] = parts[i].slice(eq+1);
|
|
5677
|
+
}
|
|
5678
|
+
// convenience: if the name is systemctl-status or journalctl,
|
|
5679
|
+
// auto-fill <name> from the selected service
|
|
5680
|
+
if((name === 'systemctl-status' || name === 'journalctl' || name === 'tail') && !params.name && !params.path){
|
|
5681
|
+
var svc = ($('pk-shell-svc')||{}).value || '';
|
|
5682
|
+
if(name === 'tail' && svc) params.path = svc + '.log';
|
|
5683
|
+
else if(svc) params.name = svc;
|
|
5684
|
+
}
|
|
5685
|
+
if(name === 'tail' && !params.n) params.n = String(parseInt(($('pk-shell-tail')||{}).value, 10) || 200);
|
|
5686
|
+
// log the command itself
|
|
5687
|
+
_shellAppend(robotId, 'input', '$ '+raw);
|
|
5688
|
+
inp.value = '';
|
|
5689
|
+
_shellExec(robotId, name, params);
|
|
5690
|
+
});
|
|
5691
|
+
var cmdInp = $('pk-shell-cmd');
|
|
5692
|
+
if(cmdInp) cmdInp.addEventListener('keydown', function(ev){
|
|
5693
|
+
if(ev.key === 'Enter'){ ev.preventDefault(); var b=$('pk-shell-run'); if(b) b.click(); }
|
|
5694
|
+
});
|
|
5695
|
+
var autoBtn = $('pk-shell-autoscroll');
|
|
5696
|
+
if(autoBtn) autoBtn.addEventListener('click', function(){
|
|
5697
|
+
var o = $('pk-shell-out'); if(!o) return;
|
|
5698
|
+
o.__autoscroll = !o.__autoscroll;
|
|
5699
|
+
autoBtn.classList.toggle('primary', !!o.__autoscroll);
|
|
5700
|
+
if(o.__autoscroll) o.scrollTop = o.scrollHeight;
|
|
5701
|
+
});
|
|
5702
|
+
if(autoBtn) autoBtn.classList.add('primary');
|
|
5703
|
+
var clearBtn = $('pk-shell-clear');
|
|
5704
|
+
if(clearBtn) clearBtn.addEventListener('click', function(){
|
|
5705
|
+
_shellBuffers[robotId] = { lines:[], lastReqId:null, lastService:'' };
|
|
5706
|
+
_renderShellBuffer(robotId);
|
|
5707
|
+
_setShellStatus(robotId, 'cleared');
|
|
5708
|
+
});
|
|
5709
|
+
// user scrolled up disables autoscroll
|
|
5710
|
+
if($out) $out.addEventListener('scroll', function(){
|
|
5711
|
+
var atBottom = ($out.scrollHeight - $out.scrollTop - $out.clientHeight) < 8;
|
|
5712
|
+
$out.__autoscroll = atBottom;
|
|
5713
|
+
var ab = $('pk-shell-autoscroll'); if(ab) ab.classList.toggle('primary', atBottom);
|
|
5714
|
+
});
|
|
5715
|
+
}
|
|
5716
|
+
function _setShellStatus(robotId, text, kind){
|
|
5717
|
+
var el = $('pk-shell-stateline'); if(!el) return;
|
|
5718
|
+
el.textContent = text;
|
|
5719
|
+
el.style.color = kind === 'error' ? 'var(--bad)' : kind === 'ok' ? 'var(--ok)' : 'var(--muted)';
|
|
5720
|
+
}
|
|
5721
|
+
// POST /api/robots/{id}/shell/tail and long-poll the result.
|
|
5722
|
+
function _shellTail(robotId, service, lines){
|
|
5723
|
+
_setShellStatus(robotId, 'tailing '+service+' (waiting for robot…)');
|
|
5724
|
+
_shellAppend(robotId, 'status', '—— tail '+service+'.log, last '+lines+' lines ——');
|
|
5725
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/tail',{service:service, params:{lines:lines}})
|
|
5726
|
+
.then(function(q){
|
|
5727
|
+
return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, service, 'tail');
|
|
5728
|
+
})
|
|
5729
|
+
.catch(function(e){ _setShellStatus(robotId, 'tail failed: '+e.message, 'error'); _shellAppend(robotId, 'status', '✗ tail failed: '+e.message); });
|
|
5730
|
+
}
|
|
5731
|
+
function _shellExec(robotId, name, params){
|
|
5732
|
+
_setShellStatus(robotId, 'running '+name+' (waiting for robot…)');
|
|
5733
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/exec',{params:{name:name, params:params}})
|
|
5734
|
+
.then(function(q){
|
|
5735
|
+
return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, '', name);
|
|
5736
|
+
})
|
|
5737
|
+
.catch(function(e){ _setShellStatus(robotId, 'exec failed: '+e.message, 'error'); _shellAppend(robotId, 'status', '✗ exec failed: '+e.message); });
|
|
5738
|
+
}
|
|
5739
|
+
// Long-poll the result endpoint. The scheduler blocks up to 8s
|
|
5740
|
+
// before returning timed_out: true; we just respect that and
|
|
5741
|
+
// give the operator a clear "still waiting" line in the output.
|
|
5742
|
+
function _shellWaitForResult(robotId, requestId, deviceId, service, label){
|
|
5743
|
+
var url = '/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/wait/'+encodeURIComponent(requestId)+'?timeout=8';
|
|
5744
|
+
var pollOnce = function(){
|
|
5745
|
+
return getJson(url).then(function(r){
|
|
5746
|
+
if(r && r.completed){
|
|
5747
|
+
var result = r.result || {};
|
|
5748
|
+
_renderShellResult(robotId, result, label);
|
|
5749
|
+
return;
|
|
5750
|
+
}
|
|
5751
|
+
// Not done yet — fall through to manual polling
|
|
5752
|
+
throw new Error('not_ready');
|
|
5753
|
+
});
|
|
5754
|
+
};
|
|
5755
|
+
return pollOnce().catch(function(){
|
|
5756
|
+
// Manual poll loop (covers the case where /wait timed out but
|
|
5757
|
+
// the robot is still chewing). Cap at 12s of fallback polling.
|
|
5758
|
+
var deadline = Date.now() + 12000;
|
|
5759
|
+
var tick = function(){
|
|
5760
|
+
if(Date.now() > deadline){
|
|
5761
|
+
_setShellStatus(robotId, '⚠ '+label+' did not complete in 20s — robot may be offline', 'error');
|
|
5762
|
+
_shellAppend(robotId, 'status', '✗ '+label+' did not complete in 20s');
|
|
5763
|
+
return;
|
|
5764
|
+
}
|
|
5765
|
+
getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/result/'+encodeURIComponent(requestId))
|
|
5766
|
+
.then(function(r){
|
|
5767
|
+
if(r && r.completed){ _renderShellResult(robotId, r.result || {}, label); return; }
|
|
5768
|
+
setTimeout(tick, 500);
|
|
5769
|
+
})
|
|
5770
|
+
.catch(function(){ setTimeout(tick, 800); });
|
|
5771
|
+
};
|
|
5772
|
+
tick();
|
|
5773
|
+
});
|
|
5774
|
+
}
|
|
5775
|
+
function _renderShellResult(robotId, result, label){
|
|
5776
|
+
if(!result || !result.ok){
|
|
5777
|
+
var err = (result && result.error) || '(no response)';
|
|
5778
|
+
_setShellStatus(robotId, '✗ '+label+' failed: '+err, 'error');
|
|
5779
|
+
_shellAppend(robotId, 'stderr', '✗ '+err);
|
|
5780
|
+
_renderShellBuffer(robotId);
|
|
5781
|
+
return;
|
|
5782
|
+
}
|
|
5783
|
+
var exit = (result.exit_code === 0) ? '✓ exit 0' : ('exit '+result.exit_code);
|
|
5784
|
+
_setShellStatus(robotId, exit, result.exit_code === 0 ? 'ok' : 'error');
|
|
5785
|
+
if(result.stdout){ _shellAppend(robotId, 'stdout', result.stdout); }
|
|
5786
|
+
if(result.stderr){ _shellAppend(robotId, 'stderr', result.stderr); }
|
|
5787
|
+
if(result.truncated){ _shellAppend(robotId, 'status', '⚠ output truncated to '+SHELL_OUTPUT_LIMIT+' bytes'); }
|
|
5788
|
+
if(result.exit_code !== 0){ _shellAppend(robotId, 'status', '—— '+label+' finished with exit '+result.exit_code+' ——'); }
|
|
5789
|
+
else { _shellAppend(robotId, 'status', '—— '+label+' finished ——'); }
|
|
5790
|
+
_renderShellBuffer(robotId);
|
|
5791
|
+
}
|
|
5792
|
+
// 64KB cap mirrors the robot's SHELL_OUTPUT_MAX_BYTES
|
|
5793
|
+
var SHELL_OUTPUT_LIMIT = 64 * 1024;
|
|
5794
|
+
|
|
4969
5795
|
function buildDrawer(n){
|
|
4970
5796
|
var b=$('pk-body');
|
|
4971
5797
|
if(n.kind==='robot' && !n.connected){
|
|
@@ -5022,18 +5848,32 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5022
5848
|
+'<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>'
|
|
5023
5849
|
+'<label class="pk-prodtoggle"><input type="checkbox" id="pk-prodmode"'+(devForPm.production_mode?' checked':'')+'><span>Production mode — continuous motion-triggered loop, camera always live</span></label>')
|
|
5024
5850
|
+ 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>')
|
|
5025
|
-
+ 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">
|
|
5851
|
+
+ 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">TTS plays on the robot speaker — use “Test speaker” below to round-trip a tone and confirm the audio path</div>'
|
|
5852
|
+
+ '<div class="rp-form" id="pk-spk-test" style="grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.55rem;padding:0.55rem;border-color:var(--border-strong);">'
|
|
5853
|
+
+ '<div class="rp-field"><label>Frequency</label><select id="pk-spk-freq"><option value="500">500 Hz (low)</option><option value="1000" selected>1000 Hz (1 kHz reference)</option><option value="2000">2000 Hz (presence)</option><option value="3000">3000 Hz (clarity)</option></select></div>'
|
|
5854
|
+
+ '<div class="rp-field"><label>Duration</label><select id="pk-spk-dur"><option value="0.4">0.4 s</option><option value="0.6" selected>0.6 s</option><option value="1.0">1.0 s</option></select></div>'
|
|
5855
|
+
+ '<div style="grid-column:1/-1;display:flex;gap:0.4rem;align-items:center;">'
|
|
5856
|
+
+ '<button class="rp-btn primary" id="pk-spk-runtest" data-robot="'+esc(schedId(n))+'">▶ Test speaker</button>'
|
|
5857
|
+
+ '<span class="pk-note" id="pk-spk-test-stateline" style="margin:0">awaiting test</span>'
|
|
5858
|
+
+ '</div>'
|
|
5859
|
+
+ '<div style="grid-column:1/-1;" id="pk-spk-test-result"></div>'
|
|
5860
|
+
+ '<div style="grid-column:1/-1;" id="pk-spk-test-history"></div>'
|
|
5861
|
+
+ '</div>')
|
|
5026
5862
|
+ 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>')
|
|
5027
5863
|
+ 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>')
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5864
|
+
+ 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>')
|
|
5865
|
+
+ sect('🩺 robot services','<div id="pk-svc-list" class="pk-svc-list"><div class="pk-note">waiting for supervisor report…</div></div><div class="pk-note" id="pk-svc-age" style="margin-top:0.35rem;"></div><div class="rp-field" style="margin-top:0.55rem;"><label>Quick run (on the robot, from packages/robopark/scheduler)</label><input id="pk-svc-clicmd" readonly value="python robot_supervisor.py"></div><button class="rp-btn" id="pk-svc-clicmd-copy" style="margin-top:0.35rem;">📋 Copy command</button>')
|
|
5866
|
+
+ buildShellSection(schedId(n))
|
|
5867
|
+
+ sect('🎭 character / voice','<div class="pk-note" id="pk-eff-note" style="margin-bottom:0.45rem;">loading effective config…</div><div class="rp-field"><label>Character preset</label><select class="rp-char" id="pk-eff-char" data-robot="'+esc(schedId(n))+'">'+effPresetOptions+'</select></div><div class="rp-field"><label>Voice stack</label><select class="rp-voice" id="pk-eff-voice" data-robot="'+esc(schedId(n))+'">'+effVoiceOptions+'</select></div><button class="rp-btn" id="pk-eff-save" data-robot="'+esc(schedId(n))+'" style="margin-top:0.35rem;">Set up on this robot</button>');
|
|
5031
5868
|
b.innerHTML += sect('Camera + audio devices','<div class="rp-field"><label>Search devices</label><input id="pk-device-search" placeholder="camera, USB, microphone, HDMI..." autocomplete="off"></div><div class="rp-field"><label>Camera input</label><select id="pk-video-device"></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input"></select></div><div class="rp-field"><label>Speaker / output inventory</label><select id="pk-audio-output"></select></div><div class="rp-field"><label>Preset greeting phrases <span class="pk-note">one per line, cached per voice</span></label><textarea id="pk-greeting-phrases" rows="3" placeholder="Hi, I am Jaguar! / Welcome to RoboPark."></textarea></div><button class="rp-btn" id="pk-device-save" style="margin-top:0.35rem">Save device selection + greetings</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
|
|
5032
5869
|
refreshDeviceSelectors(n);
|
|
5033
5870
|
b.innerHTML += '<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn danger" id="pk-recover" data-robot="'+esc(n.name)+'">Recover robot</button><span class="pk-note" style="margin:0">clears stale sessions and queued triggers for this robot</span></div>';
|
|
5034
5871
|
var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
|
|
5035
5872
|
var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
|
|
5036
5873
|
startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
|
|
5874
|
+
_bindShellSection(n);
|
|
5875
|
+
_spkTestRenderHistory(schedId(n));
|
|
5876
|
+
var runSpkTest = $('pk-spk-runtest'); if(runSpkTest) runSpkTest.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n); });
|
|
5037
5877
|
var gl=$('pk-golive'); if(gl) gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
|
|
5038
5878
|
var pm=$('pk-prodmode'); if(pm) pm.addEventListener('change', function(){ if(rpReadOnly){ pm.checked=!pm.checked; showOut('read-only node'); return; } setProductionMode(n, pm.checked); });
|
|
5039
5879
|
var svcCopy=$('pk-svc-clicmd-copy'); if(svcCopy) svcCopy.addEventListener('click', function(){ var i=$('pk-svc-clicmd'); i.select(); try{document.execCommand('copy');}catch(e){} showOut('command copied — run it on the robot itself'); });
|
|
@@ -5190,15 +6030,183 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5190
6030
|
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)'; }
|
|
5191
6031
|
set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'—');
|
|
5192
6032
|
set('pk-micstate', micAnalyser?(lvl>0.12?'live · hearing audio':'live · quiet'):'no live mic track');
|
|
5193
|
-
// SPEAKER — the Pi plays TTS out its own speaker;
|
|
5194
|
-
// track, so
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
6033
|
+
// SPEAKER — the Pi plays TTS out its own speaker; we don't get a
|
|
6034
|
+
// published track, so the meter stays at "—" unless a speaker
|
|
6035
|
+
// round-trip test is currently running. While a test plays, the
|
|
6036
|
+
// bars animate to show the live mic picking up the tone.
|
|
6037
|
+
var liveRms = window.__pkSpkLiveRms || 0;
|
|
6038
|
+
var pass_ = window.__pkSpkLivePass;
|
|
6039
|
+
for(var j=0;j<si.length;j++){
|
|
6040
|
+
var on = j<liveRms*si.length;
|
|
6041
|
+
si[j].style.height=(3+(on?liveRms*14:0))+'px';
|
|
6042
|
+
si[j].style.background = on
|
|
6043
|
+
? (pass_===true ? 'var(--ok)'
|
|
6044
|
+
: pass_===false ? 'var(--bad)'
|
|
6045
|
+
: (j<si.length*0.7?'var(--ok)':j<si.length*0.9?'var(--warn)':'var(--bad)')))
|
|
6046
|
+
: 'var(--glass-border)';
|
|
6047
|
+
}
|
|
6048
|
+
set('pk-spkval', liveRms>0 ? Math.round(liveRms*100)+'%' : '—');
|
|
6049
|
+
if(swx){
|
|
6050
|
+
swx.clearRect(0,0,sw.width,sw.height);
|
|
6051
|
+
// baseline
|
|
6052
|
+
swx.strokeStyle='rgba(255,255,255,0.10)';
|
|
6053
|
+
swx.lineWidth=1; swx.beginPath(); swx.moveTo(0,sw.height/2); swx.lineTo(sw.width,sw.height/2); swx.stroke();
|
|
6054
|
+
// recorded waveform (when we have one)
|
|
6055
|
+
var wav = window.__pkSpkWaveform;
|
|
6056
|
+
if(wav && wav.length){
|
|
6057
|
+
swx.strokeStyle = pass_===true ? 'rgba(110,224,166,0.95)'
|
|
6058
|
+
: pass_===false ? 'rgba(220,80,80,0.95)'
|
|
6059
|
+
: 'rgba(255,214,112,0.85)';
|
|
6060
|
+
swx.lineWidth=1.4; swx.beginPath();
|
|
6061
|
+
for(var x=0;x<sw.width;x++){
|
|
6062
|
+
var idx = Math.floor(x/wav.length * wav.length);
|
|
6063
|
+
var v = wav[idx] || 0;
|
|
6064
|
+
var yy = sw.height/2 - v * (sw.height*0.45);
|
|
6065
|
+
if(x===0) swx.moveTo(x,yy); else swx.lineTo(x,yy);
|
|
6066
|
+
}
|
|
6067
|
+
swx.stroke();
|
|
6068
|
+
} else {
|
|
6069
|
+
swx.strokeStyle='rgba(255,255,255,0.18)'; swx.lineWidth=1.4; swx.beginPath();
|
|
6070
|
+
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); }
|
|
6071
|
+
swx.stroke();
|
|
6072
|
+
}
|
|
6073
|
+
}
|
|
5198
6074
|
meterRAF=requestAnimationFrame(loop);
|
|
5199
6075
|
})();
|
|
5200
6076
|
}
|
|
5201
6077
|
|
|
6078
|
+
// ── C2: speaker roundtrip test ──
|
|
6079
|
+
// Queues a "play tone + record mic" test on the robot's supervisor,
|
|
6080
|
+
// polls the result, and renders a PASS/FAIL banner + a tiny
|
|
6081
|
+
// history of the last 5 tests. The meter above mirrors the
|
|
6082
|
+
// recorded RMS while the test is running.
|
|
6083
|
+
var _spkTestHist = {}; // robotId -> [{t, pass, peak_db, ...}]
|
|
6084
|
+
|
|
6085
|
+
function _spkTestSetLive(robotId, data){
|
|
6086
|
+
if(data && data.live){
|
|
6087
|
+
window.__pkSpkLiveRms = data.rms || 0;
|
|
6088
|
+
window.__pkSpkLivePass = data.pass;
|
|
6089
|
+
window.__pkSpkWaveform = data.waveform || null;
|
|
6090
|
+
} else {
|
|
6091
|
+
window.__pkSpkLiveRms = 0;
|
|
6092
|
+
window.__pkSpkLivePass = undefined;
|
|
6093
|
+
window.__pkSpkWaveform = null;
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
function _spkTestRenderHistory(robotId){
|
|
6097
|
+
var host = $('pk-spk-test-history'); if(!host) return;
|
|
6098
|
+
var list = _spkTestHist[robotId] || [];
|
|
6099
|
+
if(!list.length){
|
|
6100
|
+
host.innerHTML = '<div class="pk-note" style="margin-top:0.3rem">no tests yet — press “Test speaker”</div>';
|
|
6101
|
+
return;
|
|
6102
|
+
}
|
|
6103
|
+
var rows = list.map(function(t){
|
|
6104
|
+
var cls = t.pass===true ? 'var(--ok)' : (t.pass===false ? 'var(--bad)' : 'var(--muted)');
|
|
6105
|
+
var mark = t.pass===true ? '✓' : (t.pass===false ? '✗' : '·');
|
|
6106
|
+
var when = new Date(t.t).toTimeString().slice(0,8);
|
|
6107
|
+
var peak = t.peak_db != null ? (t.peak_db+' dB') : '—';
|
|
6108
|
+
var freq = t.frequency != null ? (Math.round(t.frequency)+' Hz') : '—';
|
|
6109
|
+
return '<div class="pk-spk-hist-row"><span class="pk-spk-mark" style="color:'+cls+'">'+mark+'</span>'
|
|
6110
|
+
+ '<span class="pk-spk-when">'+when+'</span>'
|
|
6111
|
+
+ '<span class="pk-spk-freq">'+freq+'</span>'
|
|
6112
|
+
+ '<span class="pk-spk-peak">peak '+peak+'</span>'
|
|
6113
|
+
+ (t.diagnostic ? '<span class="pk-spk-diag">'+esc(t.diagnostic)+'</span>' : '')
|
|
6114
|
+
+ '</div>';
|
|
6115
|
+
}).join('');
|
|
6116
|
+
host.innerHTML = '<div class="pk-spk-hist-head">last '+list.length+' tests</div>' + rows;
|
|
6117
|
+
}
|
|
6118
|
+
function _spkTestRenderResult(robotId, result){
|
|
6119
|
+
var host = $('pk-spk-test-result'); if(!host) return;
|
|
6120
|
+
if(!result){
|
|
6121
|
+
host.innerHTML = '';
|
|
6122
|
+
return;
|
|
6123
|
+
}
|
|
6124
|
+
if(!result.ok){
|
|
6125
|
+
host.innerHTML = '<div class="pk-spk-result bad">'
|
|
6126
|
+
+ '<div class="pk-spk-banner">✗ round-trip test failed</div>'
|
|
6127
|
+
+ '<div class="pk-spk-err">'+esc(result.error||'(no response)')+'</div>'
|
|
6128
|
+
+ '</div>';
|
|
6129
|
+
return;
|
|
6130
|
+
}
|
|
6131
|
+
var banner = result.pass
|
|
6132
|
+
? '<div class="pk-spk-banner ok">✓ speaker round-trip OK</div>'
|
|
6133
|
+
: '<div class="pk-spk-banner bad">✗ no signal detected</div>';
|
|
6134
|
+
var meta = '<div class="pk-spk-meta">'
|
|
6135
|
+
+ '<div><span class="l">peak</span><span class="v">'+esc(result.recorded_peak_db+' dB')+'</span></div>'
|
|
6136
|
+
+ '<div><span class="l">RMS</span><span class="v">'+esc(result.recorded_rms_db+' dB')+'</span></div>'
|
|
6137
|
+
+ '<div><span class="l">threshold</span><span class="v">'+esc(result.threshold_db+' dB')+'</span></div>'
|
|
6138
|
+
+ '<div><span class="l">frequency</span><span class="v">'+esc(Math.round(result.frequency)+' Hz')+'</span></div>'
|
|
6139
|
+
+ '<div><span class="l">duration</span><span class="v">'+esc(result.duration+' s')+'</span></div>'
|
|
6140
|
+
+ '<div><span class="l">latency</span><span class="v">'+esc(result.duration_ms+' ms')+'</span></div>'
|
|
6141
|
+
+ '<div><span class="l">output</span><span class="v">'+esc(result.output_device)+'</span></div>'
|
|
6142
|
+
+ '<div><span class="l">input</span><span class="v">'+esc(result.input_device)+'</span></div>'
|
|
6143
|
+
+ '</div>';
|
|
6144
|
+
var diag = result.diagnostic ? '<div class="pk-spk-diag">'+esc(result.diagnostic)+'</div>' : '';
|
|
6145
|
+
host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
|
|
6146
|
+
+ banner + meta + diag + '</div>';
|
|
6147
|
+
}
|
|
6148
|
+
function runSpeakerTest(n){
|
|
6149
|
+
var robotId = schedId(n);
|
|
6150
|
+
var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = true; btn.textContent = 'testing…'; }
|
|
6151
|
+
var status = $('pk-spk-test-stateline');
|
|
6152
|
+
if(status) status.textContent = 'queued — waiting for robot…';
|
|
6153
|
+
var freq = parseFloat(($('pk-spk-freq')||{}).value || '1000');
|
|
6154
|
+
var dur = parseFloat(($('pk-spk-dur')||{}).value || '0.6');
|
|
6155
|
+
// Animate the meter with a synthetic "during-test" peak so the
|
|
6156
|
+
// operator sees something happen immediately while waiting for
|
|
6157
|
+
// the real recording.
|
|
6158
|
+
_spkTestSetLive(robotId, {live:true, rms: 0.45, pass: undefined, waveform: null});
|
|
6159
|
+
rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',
|
|
6160
|
+
{frequency: freq, duration: dur, amplitude: 0.6, threshold_db: -30})
|
|
6161
|
+
.then(function(q){
|
|
6162
|
+
// long-poll up to 8s, then fall back to 12s of result polling
|
|
6163
|
+
if(status) status.textContent = 'playing tone + recording…';
|
|
6164
|
+
return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, '', 'speaker-test');
|
|
6165
|
+
})
|
|
6166
|
+
.catch(function(e){
|
|
6167
|
+
if(status) status.textContent = '✗ test failed: '+e.message;
|
|
6168
|
+
_spkTestSetLive(robotId, {live:false});
|
|
6169
|
+
_spkTestRenderResult(robotId, {ok:false, error:e.message});
|
|
6170
|
+
if(btn){ btn.disabled = false; btn.textContent = '▶ Test speaker'; }
|
|
6171
|
+
});
|
|
6172
|
+
}
|
|
6173
|
+
// Render a synthetic "during-test" waveform so the meter animates
|
|
6174
|
+
// while waiting for the round-trip result. Called by startMeters
|
|
6175
|
+
// to keep the bars interesting during the test.
|
|
6176
|
+
function _spkTestMark(robotId, result){
|
|
6177
|
+
// Append to history (cap at 5)
|
|
6178
|
+
var list = _spkTestHist[robotId] || [];
|
|
6179
|
+
list.unshift({
|
|
6180
|
+
t: Date.now(),
|
|
6181
|
+
pass: result && result.ok ? (result.pass===true) : null,
|
|
6182
|
+
peak_db: result && result.recorded_peak_db != null ? result.recorded_peak_db : null,
|
|
6183
|
+
frequency: result && result.frequency != null ? result.frequency : null,
|
|
6184
|
+
diagnostic: result && result.diagnostic || (result && !result.ok ? result.error : null),
|
|
6185
|
+
});
|
|
6186
|
+
while(list.length > 5) list.pop();
|
|
6187
|
+
_spkTestHist[robotId] = list;
|
|
6188
|
+
_spkTestRenderHistory(robotId);
|
|
6189
|
+
}
|
|
6190
|
+
// Override the per-robot shell-result renderer for speaker_test so
|
|
6191
|
+
// we also update the speaker-test panel + meter. _shellWaitForResult
|
|
6192
|
+
// already calls _renderShellResult with the right shape.
|
|
6193
|
+
var _origRenderShellResult = _renderShellResult;
|
|
6194
|
+
_renderShellResult = function(robotId, result, label){
|
|
6195
|
+
_origRenderShellResult(robotId, result, label);
|
|
6196
|
+
if(label === 'speaker-test'){
|
|
6197
|
+
_spkTestSetLive(robotId, {live:false});
|
|
6198
|
+
_spkTestMark(robotId, result);
|
|
6199
|
+
_spkTestRenderResult(robotId, result);
|
|
6200
|
+
var status = $('pk-spk-test-stateline');
|
|
6201
|
+
if(status){
|
|
6202
|
+
if(!result || !result.ok){ status.textContent = '✗ test failed'; }
|
|
6203
|
+
else if(result.pass){ status.textContent = '✓ pass · '+result.recorded_peak_db+' dB peak'; }
|
|
6204
|
+
else { status.textContent = '✗ no signal — '+result.recorded_peak_db+' dB peak'; }
|
|
6205
|
+
}
|
|
6206
|
+
var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = false; btn.textContent = '▶ Test speaker'; }
|
|
6207
|
+
}
|
|
6208
|
+
};
|
|
6209
|
+
|
|
5202
6210
|
function openDrawerForRobot(name){
|
|
5203
6211
|
var key=String(name||'').toLowerCase(), id='pad:'+name, found=null;
|
|
5204
6212
|
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; } }
|
|
@@ -5220,6 +6228,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5220
6228
|
loadCharacterPresets();
|
|
5221
6229
|
loadVoiceStacks();
|
|
5222
6230
|
setInterval(function(){ loadCharacterPresets(); loadVoiceStacks(); }, 12000);
|
|
6231
|
+
// C3: load the operator name (or prompt for one) before the first
|
|
6232
|
+
// tab opens, so the very first audit-attributed action already has
|
|
6233
|
+
// the right name attached.
|
|
6234
|
+
loadOperatorName();
|
|
6235
|
+
maybePromptOperator();
|
|
5223
6236
|
initTab();
|
|
5224
6237
|
})();
|
|
5225
6238
|
</script>
|