infinicode 2.8.37 → 2.8.39

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.
@@ -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>Source</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
- return fetch(url, {cache:'no-store'}).then(function(r){
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
- s.nodes = nodes;
1143
- everOk = true; setLive(true); lastStatus = s;
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
- renderSummary(s);
1148
- renderFleet(s.nodes||[]);
1149
- renderHw(s.hardware);
1150
- renderFeed(s.activity||[]);
1151
- Park.onStatus(s);
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: body?{'content-type':'application/json'}:undefined,
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 qs = rpAuditFilter.category ? ('?category='+encodeURIComponent(rpAuditFilter.category)+'&limit=200') : '?limit=200';
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 src = ev.actor || '—';
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
- html += '<tr data-idx="'+i+'">'
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,'&quot;'))+'">'+esc(detShort)+'</td>'
2405
- + '<td>'+esc(src)+'</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 &amp; 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">awaiting robot audio plugin</div>')
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
- + 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>')
5029
- + 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>')
5030
- + 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>');
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; it is not published as a
5194
- // track, so report status honestly instead of inventing a level.
5195
- for(var j=0;j<si.length;j++){ si[j].style.height='3px'; si[j].style.background='var(--glass-border)'; }
5196
- set('pk-spkval','—'); set('pk-spkstate','TTS plays on the robot speaker (not streamed)');
5197
- if(swx){ swx.clearRect(0,0,sw.width,sw.height); swx.strokeStyle='rgba(255,255,255,0.12)'; swx.lineWidth=1.4; swx.beginPath(); for(var x=0;x<sw.width;x++){ var yy=sw.height/2+Math.sin(x*0.1)*1.1; if(x===0)swx.moveTo(x,yy); else swx.lineTo(x,yy); } swx.stroke(); }
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>