nexus-prime 3.2.1 → 3.2.3

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.
@@ -75,7 +75,7 @@
75
75
  align-items: center;
76
76
  justify-content: space-between;
77
77
  gap: 1rem;
78
- padding: 1.2rem 1.6rem;
78
+ padding: 1rem 1.4rem;
79
79
  border-bottom: 1px solid var(--line);
80
80
  background: rgba(5, 8, 13, 0.76);
81
81
  backdrop-filter: blur(28px);
@@ -99,14 +99,38 @@
99
99
 
100
100
  h1 {
101
101
  margin: 0;
102
- font-size: clamp(1.35rem, 2vw, 1.9rem);
102
+ font-size: clamp(1.18rem, 1.8vw, 1.62rem);
103
103
  letter-spacing: -0.04em;
104
104
  }
105
105
 
106
106
  .subtitle {
107
107
  margin-top: 0.15rem;
108
108
  color: var(--muted);
109
- font-size: 0.92rem;
109
+ font-size: 0.84rem;
110
+ }
111
+
112
+ .banner {
113
+ margin: 0 1rem;
114
+ margin-top: 0.85rem;
115
+ padding: 0.72rem 0.95rem;
116
+ border-radius: 16px;
117
+ border: 1px solid var(--line);
118
+ background: rgba(255, 255, 255, 0.03);
119
+ color: var(--muted);
120
+ font-size: 0.76rem;
121
+ line-height: 1.45;
122
+ }
123
+
124
+ .banner.warn {
125
+ border-color: rgba(255, 209, 77, 0.22);
126
+ background: rgba(255, 209, 77, 0.08);
127
+ color: #ffe7a0;
128
+ }
129
+
130
+ .banner.bad {
131
+ border-color: rgba(255, 109, 99, 0.28);
132
+ background: rgba(255, 109, 99, 0.08);
133
+ color: #ffc1bc;
110
134
  }
111
135
 
112
136
  .header-actions {
@@ -122,11 +146,12 @@
122
146
  display: inline-flex;
123
147
  align-items: center;
124
148
  gap: 0.55rem;
125
- padding: 0.58rem 0.9rem;
149
+ padding: 0.48rem 0.8rem;
126
150
  border-radius: 999px;
127
151
  border: 1px solid var(--line);
128
152
  background: rgba(255, 255, 255, 0.03);
129
153
  color: var(--muted);
154
+ font-size: 0.82rem;
130
155
  }
131
156
 
132
157
  .status-pill .dot,
@@ -166,7 +191,7 @@
166
191
  display: grid;
167
192
  grid-template-columns: 320px minmax(0, 1fr) 410px;
168
193
  gap: 1rem;
169
- padding: 1rem 1rem 1.15rem;
194
+ padding: 0.9rem 1rem 1rem;
170
195
  }
171
196
 
172
197
  .panel {
@@ -185,7 +210,7 @@
185
210
  align-items: center;
186
211
  justify-content: space-between;
187
212
  gap: 0.8rem;
188
- padding: 1rem 1.2rem;
213
+ padding: 0.85rem 1rem;
189
214
  border-bottom: 1px solid var(--line);
190
215
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
191
216
  }
@@ -193,7 +218,7 @@
193
218
  .panel-header h2,
194
219
  .panel-header h3 {
195
220
  margin: 0;
196
- font-size: 0.82rem;
221
+ font-size: 0.74rem;
197
222
  letter-spacing: 0.14em;
198
223
  text-transform: uppercase;
199
224
  color: var(--muted);
@@ -203,13 +228,13 @@
203
228
  flex: 1;
204
229
  min-height: 0;
205
230
  overflow: auto;
206
- padding: 1rem;
231
+ padding: 0.85rem;
207
232
  scrollbar-width: thin;
208
233
  scrollbar-color: rgba(188, 204, 255, 0.22) transparent;
209
234
  }
210
235
 
211
236
  .panel-body.compact {
212
- padding: 0.9rem;
237
+ padding: 0.75rem;
213
238
  }
214
239
 
215
240
  .rail {
@@ -220,6 +245,9 @@
220
245
 
221
246
  .rail.left {
222
247
  grid-template-rows: auto auto auto auto;
248
+ overflow-y: auto;
249
+ scrollbar-width: thin;
250
+ scrollbar-color: rgba(188, 204, 255, 0.22) transparent;
223
251
  }
224
252
 
225
253
  .rail.right {
@@ -235,7 +263,7 @@
235
263
  border: 1px solid var(--line);
236
264
  background: var(--panel-soft);
237
265
  border-radius: var(--radius-card);
238
- padding: 0.9rem;
266
+ padding: 0.78rem;
239
267
  }
240
268
 
241
269
  .card.interactive {
@@ -259,7 +287,7 @@
259
287
  }
260
288
 
261
289
  .card-title strong {
262
- font-size: 1rem;
290
+ font-size: 0.92rem;
263
291
  font-weight: 600;
264
292
  }
265
293
 
@@ -275,12 +303,12 @@
275
303
 
276
304
  .meta {
277
305
  color: var(--muted);
278
- font-size: 0.78rem;
306
+ font-size: 0.72rem;
279
307
  line-height: 1.5;
280
308
  }
281
309
 
282
310
  .hero-metric {
283
- padding: 1rem;
311
+ padding: 0.88rem;
284
312
  border-radius: var(--radius-card);
285
313
  border: 1px solid var(--line);
286
314
  background: linear-gradient(160deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01));
@@ -288,16 +316,16 @@
288
316
 
289
317
  .hero-metric label {
290
318
  display: block;
291
- font-size: 0.74rem;
319
+ font-size: 0.68rem;
292
320
  color: var(--muted);
293
321
  text-transform: uppercase;
294
322
  letter-spacing: 0.12em;
295
- margin-bottom: 0.5rem;
323
+ margin-bottom: 0.38rem;
296
324
  }
297
325
 
298
326
  .hero-metric strong {
299
327
  display: block;
300
- font-size: 2rem;
328
+ font-size: 1.62rem;
301
329
  letter-spacing: -0.05em;
302
330
  margin-bottom: 0.2rem;
303
331
  }
@@ -320,7 +348,7 @@
320
348
  align-items: center;
321
349
  justify-content: space-between;
322
350
  gap: 0.75rem;
323
- padding: 0.8rem 0.9rem;
351
+ padding: 0.68rem 0.78rem;
324
352
  border-radius: 16px;
325
353
  border: 1px solid var(--line);
326
354
  background: rgba(255, 255, 255, 0.03);
@@ -335,6 +363,7 @@
335
363
 
336
364
  .client-name {
337
365
  font-weight: 600;
366
+ font-size: 0.92rem;
338
367
  }
339
368
 
340
369
  .state-chip,
@@ -343,8 +372,8 @@
343
372
  align-items: center;
344
373
  justify-content: center;
345
374
  border-radius: 999px;
346
- padding: 0.23rem 0.55rem;
347
- font-size: 0.72rem;
375
+ padding: 0.2rem 0.5rem;
376
+ font-size: 0.68rem;
348
377
  border: 1px solid var(--line);
349
378
  background: rgba(255, 255, 255, 0.05);
350
379
  text-transform: uppercase;
@@ -400,8 +429,8 @@
400
429
  stroke: var(--accent);
401
430
  stroke-width: 12;
402
431
  stroke-linecap: round;
403
- stroke-dasharray: 440;
404
- stroke-dashoffset: 440;
432
+ stroke-dasharray: 439.82;
433
+ stroke-dashoffset: 439.82;
405
434
  filter: drop-shadow(0 0 10px rgba(84, 255, 135, 0.32));
406
435
  transition: stroke-dashoffset 300ms ease;
407
436
  }
@@ -415,13 +444,13 @@
415
444
  }
416
445
 
417
446
  .dial-value {
418
- font-size: 2rem;
447
+ font-size: 1.68rem;
419
448
  font-weight: 700;
420
449
  letter-spacing: -0.06em;
421
450
  }
422
451
 
423
452
  .dial-caption {
424
- font-size: 0.72rem;
453
+ font-size: 0.66rem;
425
454
  color: var(--muted);
426
455
  text-transform: uppercase;
427
456
  letter-spacing: 0.12em;
@@ -453,10 +482,11 @@
453
482
  .primary-button {
454
483
  border: 0;
455
484
  border-radius: 999px;
456
- padding: 0.5rem 0.8rem;
485
+ padding: 0.42rem 0.68rem;
457
486
  background: transparent;
458
487
  color: var(--muted);
459
488
  transition: background 160ms ease, color 160ms ease, transform 160ms ease;
489
+ font-size: 0.72rem;
460
490
  }
461
491
 
462
492
  .segmented button.active,
@@ -485,7 +515,7 @@
485
515
 
486
516
  #graph-stage {
487
517
  position: relative;
488
- min-height: 0;
518
+ min-height: 300px;
489
519
  height: 100%;
490
520
  border-radius: 22px;
491
521
  border: 1px solid var(--line);
@@ -513,7 +543,7 @@
513
543
 
514
544
  .node-label {
515
545
  font-family: var(--mono);
516
- font-size: 11px;
546
+ font-size: 9.5px;
517
547
  fill: rgba(246, 247, 251, 0.86);
518
548
  pointer-events: none;
519
549
  }
@@ -551,7 +581,7 @@
551
581
 
552
582
  .graph-note {
553
583
  color: var(--muted);
554
- font-size: 0.8rem;
584
+ font-size: 0.72rem;
555
585
  }
556
586
 
557
587
  .graph-footer {
@@ -572,11 +602,11 @@
572
602
  display: inline-flex;
573
603
  align-items: center;
574
604
  gap: 0.35rem;
575
- padding: 0.28rem 0.55rem;
605
+ padding: 0.24rem 0.48rem;
576
606
  border-radius: 999px;
577
607
  border: 1px solid var(--line);
578
608
  background: rgba(255, 255, 255, 0.05);
579
- font-size: 0.74rem;
609
+ font-size: 0.68rem;
580
610
  }
581
611
 
582
612
  .entity-header {
@@ -589,7 +619,7 @@
589
619
 
590
620
  .entity-header h3 {
591
621
  margin: 0;
592
- font-size: 0.9rem;
622
+ font-size: 0.82rem;
593
623
  letter-spacing: 0.08em;
594
624
  text-transform: uppercase;
595
625
  color: var(--muted);
@@ -599,13 +629,14 @@
599
629
  display: flex;
600
630
  flex-wrap: wrap;
601
631
  gap: 0.35rem;
632
+ justify-content: flex-end;
602
633
  }
603
634
 
604
635
  .event-card {
605
636
  border-radius: 18px;
606
637
  border: 1px solid var(--line);
607
638
  background: rgba(255, 255, 255, 0.03);
608
- padding: 0.9rem;
639
+ padding: 0.78rem;
609
640
  border-left: 3px solid var(--muted);
610
641
  }
611
642
 
@@ -623,7 +654,7 @@
623
654
 
624
655
  .event-head strong {
625
656
  display: block;
626
- font-size: 0.96rem;
657
+ font-size: 0.88rem;
627
658
  }
628
659
 
629
660
  .event-head .meta {
@@ -631,24 +662,24 @@
631
662
  }
632
663
 
633
664
  details.raw {
634
- margin-top: 0.6rem;
665
+ margin-top: 0.45rem;
635
666
  }
636
667
 
637
668
  details.raw summary {
638
669
  cursor: pointer;
639
670
  color: var(--muted);
640
- font-size: 0.74rem;
671
+ font-size: 0.68rem;
641
672
  text-transform: uppercase;
642
673
  letter-spacing: 0.08em;
643
674
  }
644
675
 
645
676
  pre {
646
677
  overflow: auto;
647
- padding: 0.8rem;
678
+ padding: 0.68rem;
648
679
  border-radius: 14px;
649
680
  background: rgba(0, 0, 0, 0.3);
650
681
  border: 1px solid var(--line);
651
- font-size: 0.75rem;
682
+ font-size: 0.7rem;
652
683
  line-height: 1.5;
653
684
  margin: 0.55rem 0 0;
654
685
  white-space: pre-wrap;
@@ -666,7 +697,7 @@
666
697
  }
667
698
 
668
699
  .field label {
669
- font-size: 0.74rem;
700
+ font-size: 0.68rem;
670
701
  color: var(--muted);
671
702
  text-transform: uppercase;
672
703
  letter-spacing: 0.08em;
@@ -676,7 +707,7 @@
676
707
  .field select,
677
708
  .field textarea {
678
709
  width: 100%;
679
- padding: 0.7rem 0.78rem;
710
+ padding: 0.62rem 0.72rem;
680
711
  border-radius: 14px;
681
712
  border: 1px solid var(--line);
682
713
  background: rgba(255, 255, 255, 0.03);
@@ -723,6 +754,21 @@
723
754
  transform: translateX(0);
724
755
  }
725
756
 
757
+ .drawer-backdrop {
758
+ position: fixed;
759
+ inset: 0;
760
+ background: rgba(0, 0, 0, 0.4);
761
+ z-index: 19;
762
+ opacity: 0;
763
+ pointer-events: none;
764
+ transition: opacity 220ms ease;
765
+ }
766
+
767
+ .drawer-backdrop.open {
768
+ opacity: 1;
769
+ pointer-events: auto;
770
+ }
771
+
726
772
  .drawer-header {
727
773
  display: flex;
728
774
  justify-content: space-between;
@@ -741,12 +787,12 @@
741
787
  border: 1px solid var(--line);
742
788
  background: rgba(255, 255, 255, 0.03);
743
789
  border-radius: 18px;
744
- padding: 0.9rem;
790
+ padding: 0.78rem;
745
791
  }
746
792
 
747
793
  .drawer-section h4 {
748
794
  margin: 0 0 0.55rem;
749
- font-size: 0.82rem;
795
+ font-size: 0.74rem;
750
796
  color: var(--muted);
751
797
  text-transform: uppercase;
752
798
  letter-spacing: 0.08em;
@@ -769,10 +815,20 @@
769
815
  .library-grid {
770
816
  display: grid;
771
817
  gap: 0.7rem;
772
- max-height: 15rem;
818
+ max-height: 28rem;
773
819
  overflow: auto;
774
820
  }
775
821
 
822
+ .loading-pulse {
823
+ opacity: 0.5;
824
+ animation: pulse 1.2s ease-in-out infinite;
825
+ }
826
+
827
+ @keyframes pulse {
828
+ 0%, 100% { opacity: 0.5; }
829
+ 50% { opacity: 0.3; }
830
+ }
831
+
776
832
  .hidden {
777
833
  display: none !important;
778
834
  }
@@ -799,7 +855,7 @@
799
855
  }
800
856
 
801
857
  .graph-panel {
802
- min-height: 34rem;
858
+ min-height: 24rem;
803
859
  }
804
860
  }
805
861
 
@@ -836,6 +892,8 @@
836
892
  </div>
837
893
  </header>
838
894
 
895
+ <div id="status-banner" class="banner hidden" role="status" aria-live="polite"></div>
896
+
839
897
  <main class="layout">
840
898
  <aside class="rail left">
841
899
  <section class="panel">
@@ -1066,8 +1124,16 @@
1066
1124
  <div class="empty">Touch a memory, run, workflow, skill, POD worker, or client to inspect it.</div>
1067
1125
  </div>
1068
1126
  </aside>
1127
+ <div id="drawer-backdrop" class="drawer-backdrop"></div>
1069
1128
 
1070
1129
  <script>
1130
+ const DASHBOARD_API_VERSION = '2';
1131
+ const REQUIRED_CAPABILITIES = ['runs', 'memory', 'pod', 'clients', 'events', 'stream'];
1132
+
1133
+ function createResourceState() {
1134
+ return { status: 'idle', error: '' };
1135
+ }
1136
+
1071
1137
  const state = {
1072
1138
  runs: [],
1073
1139
  skills: [],
@@ -1086,6 +1152,20 @@
1086
1152
  selected: null,
1087
1153
  streamConnected: false,
1088
1154
  lastRefreshAt: 0,
1155
+ banner: null,
1156
+ resources: {
1157
+ health: createResourceState(),
1158
+ backends: createResourceState(),
1159
+ runs: createResourceState(),
1160
+ skills: createResourceState(),
1161
+ workflows: createResourceState(),
1162
+ memory: createResourceState(),
1163
+ pod: createResourceState(),
1164
+ clients: createResourceState(),
1165
+ events: createResourceState(),
1166
+ memoryDetail: createResourceState(),
1167
+ memoryNetwork: createResourceState(),
1168
+ },
1089
1169
  };
1090
1170
 
1091
1171
  const graphModes = {
@@ -1147,61 +1227,323 @@
1147
1227
  .filter(Boolean);
1148
1228
  }
1149
1229
 
1230
+ function setResourceStatus(name, status, error = '') {
1231
+ state.resources[name] = { status, error };
1232
+ }
1233
+
1234
+ function getResourceStatus(name) {
1235
+ return state.resources[name] || createResourceState();
1236
+ }
1237
+
1238
+ function resourceFailed(name) {
1239
+ return getResourceStatus(name).status === 'error';
1240
+ }
1241
+
1242
+ function countFailedResources() {
1243
+ return Object.values(state.resources).filter((resource) => resource.status === 'error').length;
1244
+ }
1245
+
1246
+ function setBanner(kind, message) {
1247
+ state.banner = message ? { kind, message } : null;
1248
+ }
1249
+
1250
+ function clearBanner() {
1251
+ state.banner = null;
1252
+ }
1253
+
1254
+ function normalizeError(error) {
1255
+ return error instanceof Error ? error.message : String(error || 'Unknown error');
1256
+ }
1257
+
1258
+ function reconcileBanner() {
1259
+ const healthStatus = getResourceStatus('health');
1260
+ if (healthStatus.status === 'error') {
1261
+ setBanner('bad', 'Connected to stale dashboard server or an incompatible API surface. Open the latest dashboard URL printed by the MCP process.');
1262
+ return;
1263
+ }
1264
+
1265
+ const version = state.health?.dashboardApiVersion;
1266
+ const capabilities = state.health?.capabilities || {};
1267
+ const missingCapabilities = REQUIRED_CAPABILITIES.filter((capability) => capabilities[capability] !== true);
1268
+
1269
+ if (!version || version !== DASHBOARD_API_VERSION || missingCapabilities.length) {
1270
+ setBanner('bad', `Dashboard compatibility mismatch detected. Expected API v${DASHBOARD_API_VERSION}; missing capabilities: ${missingCapabilities.join(', ') || 'version marker'}.`);
1271
+ return;
1272
+ }
1273
+
1274
+ const failed = Object.entries(state.resources)
1275
+ .filter(([name, resource]) => resource.status === 'error' && !['memoryDetail', 'memoryNetwork'].includes(name))
1276
+ .map(([name]) => name);
1277
+
1278
+ if (failed.length) {
1279
+ setBanner('warn', `Dashboard is partially degraded. Unavailable surfaces: ${failed.join(', ')}.`);
1280
+ return;
1281
+ }
1282
+
1283
+ clearBanner();
1284
+ }
1285
+
1286
+ function emptyState(message) {
1287
+ return `<div class="empty">${escapeHtml(message)}</div>`;
1288
+ }
1289
+
1290
+ function normalizeLegacyCategory(type) {
1291
+ if (String(type).startsWith('memory.')) return 'memory';
1292
+ if (String(type).startsWith('pod.')) return 'pod';
1293
+ if (String(type).startsWith('phantom.')) return 'runtime';
1294
+ if (String(type).startsWith('client.')) return 'clients';
1295
+ if (String(type).startsWith('skill.')) return 'skills';
1296
+ if (String(type).startsWith('workflow.')) return 'workflows';
1297
+ if (String(type).startsWith('tokens.') || String(type).startsWith('cas.') || String(type).startsWith('kv.')) return 'tokens';
1298
+ return 'system';
1299
+ }
1300
+
1301
+ function normalizeLegacySeverity(type, payload) {
1302
+ if (type === 'guardrail.check') return payload?.passed ? 'good' : 'bad';
1303
+ if (['phantom.merge', 'phantom.merge.complete', 'workflow.run'].includes(type)) {
1304
+ return payload?.status === 'failed' ? 'bad' : 'good';
1305
+ }
1306
+ if (type === 'client.inferred') return 'warn';
1307
+ if (type === 'dashboard.action' && payload?.status === 'failed') return 'bad';
1308
+ if (type === 'pod.signal') return 'info';
1309
+ return 'info';
1310
+ }
1311
+
1312
+ function legacyTitle(type) {
1313
+ return {
1314
+ 'system.boot': 'Runtime boot',
1315
+ 'memory.store': 'Memory stored',
1316
+ 'memory.recall': 'Memory recall',
1317
+ 'pod.signal': 'POD signal',
1318
+ 'tokens.optimized': 'Tokens optimized',
1319
+ 'phantom.worker.start': 'Worker start',
1320
+ 'phantom.worker.complete': 'Worker complete',
1321
+ 'phantom.merge.complete': 'Merge complete',
1322
+ 'phantom.merge': 'Merge decision',
1323
+ 'guardrail.check': 'Guardrail check',
1324
+ 'ghost.pass': 'Ghost pass',
1325
+ 'graph.query': 'Graph query',
1326
+ 'darwin.cycle': 'Darwin cycle',
1327
+ 'session.dna': 'Session DNA',
1328
+ 'skill.register': 'Skill registered',
1329
+ 'skill.deploy': 'Skill deployed',
1330
+ 'skill.revoke': 'Skill revoked',
1331
+ 'workflow.deploy': 'Workflow deployed',
1332
+ 'workflow.run': 'Workflow run',
1333
+ 'client.heartbeat': 'Client heartbeat',
1334
+ 'client.inferred': 'Client inferred',
1335
+ 'client.status': 'Client status',
1336
+ 'dashboard.action': 'Dashboard action',
1337
+ 'nexusnet.publish': 'NexusNet publish',
1338
+ 'nexusnet.sync': 'NexusNet sync',
1339
+ 'entanglement.create': 'Entanglement created',
1340
+ 'entanglement.collapse': 'Entanglement collapsed',
1341
+ 'entanglement.correlate': 'Entanglement correlated',
1342
+ 'cas.encode': 'CAS encode',
1343
+ 'cas.decode': 'CAS decode',
1344
+ 'cas.pattern_learned': 'CAS pattern',
1345
+ 'kv.merge': 'KV merge',
1346
+ 'kv.adapt': 'KV adapt',
1347
+ 'kv.consensus': 'KV consensus',
1348
+ }[type] || String(type || 'event');
1349
+ }
1350
+
1351
+ function legacySource(type, payload) {
1352
+ if (String(type).startsWith('client.')) return String(payload?.displayName || payload?.clientId || 'client');
1353
+ if (type === 'pod.signal') return String(payload?.workerId || 'pod');
1354
+ if (String(type).startsWith('phantom.')) return String(payload?.workerId || payload?.winner || 'runtime');
1355
+ if (String(type).startsWith('skill.')) return String(payload?.skillId || payload?.name || 'skill');
1356
+ if (String(type).startsWith('workflow.')) return String(payload?.workflowId || 'workflow');
1357
+ return 'nexus-prime';
1358
+ }
1359
+
1360
+ function legacySummary(type, payload) {
1361
+ switch (type) {
1362
+ case 'memory.store':
1363
+ return `Priority ${payload?.priority ?? 'n/a'} · ${(payload?.tags || []).join(', ') || 'no tags'}`;
1364
+ case 'memory.recall':
1365
+ return `Recalled ${payload?.count ?? 0} memories for "${payload?.query || ''}"`;
1366
+ case 'pod.signal':
1367
+ return String(payload?.content || 'POD signal received');
1368
+ case 'tokens.optimized':
1369
+ return `Saved ${payload?.savings ?? 0} tokens across ${payload?.files ?? 0} files`;
1370
+ case 'phantom.worker.start':
1371
+ return `${payload?.approach || 'worker'} started for ${payload?.goal || 'task'}`;
1372
+ case 'phantom.worker.complete':
1373
+ return `Confidence ${payload?.confidence ?? 0}`;
1374
+ case 'phantom.merge':
1375
+ return `${payload?.action || 'merge'} · ${payload?.winner || 'unknown winner'}`;
1376
+ case 'dashboard.action':
1377
+ return `${payload?.action || 'action'} → ${payload?.status || 'unknown'}`;
1378
+ default:
1379
+ return typeof payload === 'string' ? payload : JSON.stringify(payload || {});
1380
+ }
1381
+ }
1382
+
1383
+ function normalizeEventCard(raw) {
1384
+ if (!raw) return null;
1385
+
1386
+ if (raw.category && raw.title && raw.time) {
1387
+ return {
1388
+ id: raw.id || `evt-${raw.type || raw.category}-${raw.time}-${raw.source || 'nexus-prime'}`,
1389
+ type: raw.type || raw.category,
1390
+ title: String(raw.title),
1391
+ source: String(raw.source || 'nexus-prime'),
1392
+ time: Number(raw.time) || Date.now(),
1393
+ severity: ['good', 'info', 'warn', 'bad'].includes(raw.severity) ? raw.severity : 'info',
1394
+ category: raw.category,
1395
+ summary: String(raw.summary || ''),
1396
+ payload: raw.payload ?? raw,
1397
+ };
1398
+ }
1399
+
1400
+ if (raw.type && raw.timestamp) {
1401
+ const payload = raw.data || {};
1402
+ return {
1403
+ id: raw.id || `evt-${raw.type}-${raw.timestamp}-${legacySource(raw.type, payload)}`,
1404
+ type: raw.type,
1405
+ title: legacyTitle(raw.type),
1406
+ source: legacySource(raw.type, payload),
1407
+ time: Number(raw.timestamp) || Date.now(),
1408
+ severity: normalizeLegacySeverity(raw.type, payload),
1409
+ category: normalizeLegacyCategory(raw.type),
1410
+ summary: legacySummary(raw.type, payload),
1411
+ payload,
1412
+ };
1413
+ }
1414
+
1415
+ return {
1416
+ id: `evt-legacy-${Date.now()}`,
1417
+ type: 'system.legacy',
1418
+ title: 'Legacy event',
1419
+ source: 'nexus-prime',
1420
+ time: Date.now(),
1421
+ severity: 'info',
1422
+ category: 'system',
1423
+ summary: typeof raw === 'string' ? raw : 'Malformed event payload received',
1424
+ payload: raw,
1425
+ };
1426
+ }
1427
+
1150
1428
  async function fetchJson(url, options) {
1151
1429
  const res = await fetch(url, options);
1430
+ const text = await res.text();
1152
1431
  if (!res.ok) {
1153
- throw new Error(`${res.status} ${res.statusText}`);
1432
+ throw new Error(`${res.status} ${res.statusText}${text ? ` · ${text.slice(0, 120)}` : ''}`);
1154
1433
  }
1155
- return res.json();
1434
+ return text ? JSON.parse(text) : null;
1156
1435
  }
1157
1436
 
1158
1437
  async function refreshAll() {
1159
- const [runs, skills, workflows, backends, health, memories, pod, clients, events] = await Promise.all([
1160
- fetchJson('/api/runs?limit=20'),
1161
- fetchJson('/api/skills'),
1162
- fetchJson('/api/workflows'),
1163
- fetchJson('/api/backends'),
1164
- fetchJson('/api/health'),
1165
- fetchJson('/api/memory?limit=40'),
1166
- fetchJson('/api/pod?limit=30'),
1167
- fetchJson('/api/clients'),
1168
- fetchJson('/api/events?limit=80'),
1169
- ]);
1438
+ document.querySelector('.layout')?.classList.add('loading-pulse');
1439
+ const resources = [
1440
+ ['runs', '/api/runs?limit=20', (value) => { state.runs = Array.isArray(value) ? value : state.runs; }],
1441
+ ['skills', '/api/skills', (value) => { state.skills = Array.isArray(value) ? value : state.skills; }],
1442
+ ['workflows', '/api/workflows', (value) => { state.workflows = Array.isArray(value) ? value : state.workflows; }],
1443
+ ['backends', '/api/backends', (value) => { state.backends = value || state.backends; }],
1444
+ ['health', '/api/health', (value) => { state.health = value || state.health; }],
1445
+ ['memory', '/api/memory?limit=40', (value) => { state.memories = Array.isArray(value) ? value : state.memories; }],
1446
+ ['pod', '/api/pod?limit=30', (value) => { state.pod = value || state.pod; }],
1447
+ ['clients', '/api/clients', (value) => { state.clients = Array.isArray(value) ? value : state.clients; }],
1448
+ ['events', '/api/events?limit=80', (value) => {
1449
+ if (Array.isArray(value)) {
1450
+ const seen = new Set();
1451
+ state.events = value.map((event) => normalizeEventCard(event)).filter((e) => {
1452
+ if (!e || seen.has(e.id)) return false;
1453
+ seen.add(e.id);
1454
+ return true;
1455
+ });
1456
+ }
1457
+ }],
1458
+ ];
1170
1459
 
1171
- state.runs = Array.isArray(runs) ? runs : [];
1172
- state.skills = Array.isArray(skills) ? skills : [];
1173
- state.workflows = Array.isArray(workflows) ? workflows : [];
1174
- state.backends = backends || {};
1175
- state.health = health || {};
1176
- state.memories = Array.isArray(memories) ? memories : [];
1177
- state.pod = pod || state.pod;
1178
- state.clients = Array.isArray(clients) ? clients : [];
1179
- state.events = Array.isArray(events) ? events : [];
1180
- state.lastRefreshAt = Date.now();
1460
+ const results = await Promise.allSettled(resources.map(([, url]) => fetchJson(url)));
1461
+ let refreshed = false;
1462
+
1463
+ results.forEach((result, index) => {
1464
+ const [name, , assign] = resources[index];
1465
+ if (result.status === 'fulfilled') {
1466
+ assign(result.value);
1467
+ setResourceStatus(name, 'ready');
1468
+ refreshed = true;
1469
+ } else {
1470
+ setResourceStatus(name, 'error', normalizeError(result.reason));
1471
+ }
1472
+ });
1473
+
1474
+ if (refreshed) {
1475
+ state.lastRefreshAt = Date.now();
1476
+ }
1181
1477
 
1182
1478
  const focusMemoryId = state.selected?.kind === 'memory'
1183
1479
  ? state.selected.id
1184
1480
  : state.memories[0]?.id;
1185
- if (focusMemoryId) {
1481
+
1482
+ if (focusMemoryId && !resourceFailed('memory')) {
1186
1483
  await refreshMemorySelection(focusMemoryId, false);
1187
- } else {
1484
+ } else if (!focusMemoryId) {
1188
1485
  state.memoryDetail = null;
1189
1486
  state.memoryNetwork = { nodes: [], links: [], focusId: null };
1487
+ setResourceStatus('memoryDetail', 'idle');
1488
+ setResourceStatus('memoryNetwork', 'idle');
1190
1489
  }
1191
1490
 
1491
+ document.querySelector('.layout')?.classList.remove('loading-pulse');
1492
+ reconcileBanner();
1192
1493
  populateBackendSelects();
1193
1494
  render();
1194
1495
  }
1195
1496
 
1497
+ let _refreshTimer = null;
1498
+ let _refreshInFlight = false;
1499
+
1500
+ function scheduleRefresh() {
1501
+ if (_refreshTimer) return;
1502
+ _refreshTimer = setTimeout(async () => {
1503
+ _refreshTimer = null;
1504
+ if (_refreshInFlight) {
1505
+ scheduleRefresh();
1506
+ return;
1507
+ }
1508
+ _refreshInFlight = true;
1509
+ try { await refreshAll(); } catch {}
1510
+ _refreshInFlight = false;
1511
+ }, 300);
1512
+ }
1513
+
1196
1514
  async function refreshMemorySelection(memoryId, openDrawer = true) {
1197
- const [detail, network] = await Promise.all([
1515
+ const [detail, network] = await Promise.allSettled([
1198
1516
  fetchJson(`/api/memory/${encodeURIComponent(memoryId)}`),
1199
1517
  fetchJson(`/api/memory/${encodeURIComponent(memoryId)}/network?depth=2&limit=18`),
1200
1518
  ]);
1201
- state.memoryDetail = detail;
1202
- state.memoryNetwork = network;
1519
+
1520
+ if (detail.status === 'fulfilled') {
1521
+ state.memoryDetail = detail.value;
1522
+ setResourceStatus('memoryDetail', 'ready');
1523
+ } else {
1524
+ state.memoryDetail = state.memories.find((memory) => memory.id === memoryId) || null;
1525
+ setResourceStatus('memoryDetail', 'error', normalizeError(detail.reason));
1526
+ }
1527
+
1528
+ if (network.status === 'fulfilled') {
1529
+ state.memoryNetwork = network.value || { nodes: [], links: [], focusId: memoryId };
1530
+ setResourceStatus('memoryNetwork', 'ready');
1531
+ } else {
1532
+ state.memoryNetwork = {
1533
+ focusId: memoryId,
1534
+ nodes: state.memories.slice(0, 12).map((memory) => ({
1535
+ id: memory.id,
1536
+ label: memory.excerpt,
1537
+ entityType: 'memory',
1538
+ tier: memory.tier,
1539
+ })),
1540
+ links: [],
1541
+ };
1542
+ setResourceStatus('memoryNetwork', 'error', normalizeError(network.reason));
1543
+ }
1544
+
1203
1545
  if (openDrawer) {
1204
- state.selected = { kind: 'memory', id: memoryId, data: detail };
1546
+ state.selected = { kind: 'memory', id: memoryId, data: state.memoryDetail };
1205
1547
  }
1206
1548
  }
1207
1549
 
@@ -1254,6 +1596,7 @@
1254
1596
  }
1255
1597
 
1256
1598
  function render() {
1599
+ renderBanner();
1257
1600
  renderHeader();
1258
1601
  renderLeftRail();
1259
1602
  renderGraph();
@@ -1262,19 +1605,38 @@
1262
1605
  renderDrawer();
1263
1606
  }
1264
1607
 
1608
+ function renderBanner() {
1609
+ const banner = $('status-banner');
1610
+ if (!state.banner?.message) {
1611
+ banner.className = 'banner hidden';
1612
+ banner.textContent = '';
1613
+ return;
1614
+ }
1615
+
1616
+ banner.className = `banner ${state.banner.kind}`;
1617
+ banner.textContent = state.banner.message;
1618
+ }
1619
+
1265
1620
  function renderHeader() {
1266
1621
  $('header-version').textContent = `v${state.health.release?.packageVersion || '?'}`;
1267
1622
  const pill = $('sync-pill');
1268
- pill.classList.toggle('live', state.streamConnected);
1269
- pill.classList.toggle('warn', !state.streamConnected);
1623
+ const failedResources = countFailedResources();
1624
+ pill.classList.toggle('live', state.streamConnected && failedResources === 0);
1625
+ pill.classList.toggle('warn', !state.streamConnected || failedResources > 0);
1270
1626
  $('sync-label').textContent = state.streamConnected
1271
- ? `Synchronized · ${formatAgo(state.lastRefreshAt)}`
1272
- : 'Stream reconnecting';
1627
+ ? failedResources
1628
+ ? `Degraded · ${failedResources} surfaces unavailable`
1629
+ : `Synchronized · ${formatAgo(state.lastRefreshAt)}`
1630
+ : failedResources
1631
+ ? `REST degraded · ${failedResources} unavailable`
1632
+ : 'Stream reconnecting';
1273
1633
  }
1274
1634
 
1275
1635
  function renderLeftRail() {
1276
- $('clients-summary').textContent = `${state.clients.length} visible`;
1277
- $('clients-list').innerHTML = state.clients.length
1636
+ $('clients-summary').textContent = resourceFailed('clients') ? 'unavailable' : `${state.clients.length} visible`;
1637
+ $('clients-list').innerHTML = resourceFailed('clients') && !state.clients.length
1638
+ ? emptyState('Clients endpoint unavailable.')
1639
+ : state.clients.length
1278
1640
  ? state.clients.map((client) => `
1279
1641
  <div class="client-row card interactive ${state.selected?.kind === 'client' && state.selected.id === client.clientId ? 'active' : ''}" data-kind="client" data-id="${escapeHtml(client.clientId)}">
1280
1642
  <div class="client-info">
@@ -1287,37 +1649,53 @@
1287
1649
  <span class="state-chip ${stateClass(client.state)}">${escapeHtml(client.state)}</span>
1288
1650
  </div>
1289
1651
  `).join('')
1290
- : '<div class="empty">No clients detected.</div>';
1652
+ : emptyState('No clients detected.');
1653
+
1654
+ $('clients-list').querySelectorAll('.card.interactive').forEach((card) => {
1655
+ card.addEventListener('click', () => {
1656
+ void openEntity(card.getAttribute('data-kind'), card.getAttribute('data-id'));
1657
+ });
1658
+ });
1291
1659
 
1292
1660
  const tokenMetrics = computeTokenMetrics();
1293
- $('token-summary').textContent = `${tokenMetrics.events} events`;
1661
+ $('token-summary').textContent = resourceFailed('events') ? 'events unavailable' : `${tokenMetrics.events} events`;
1294
1662
  $('gross-tokens').textContent = formatNumber(tokenMetrics.gross);
1295
1663
  $('saved-tokens').textContent = formatNumber(tokenMetrics.saved);
1296
1664
  $('net-tokens').textContent = formatNumber(tokenMetrics.forwarded);
1297
- $('memory-count').textContent = formatNumber(state.memories.length);
1665
+ $('memory-count').textContent = resourceFailed('memory') && !state.memories.length ? 'n/a' : formatNumber(state.memories.length);
1298
1666
  $('dial-value').textContent = `${tokenMetrics.ratio}%`;
1299
1667
  const circumference = 2 * Math.PI * 70;
1300
1668
  const offset = circumference - (circumference * tokenMetrics.ratio / 100);
1301
1669
  $('dial-progress').style.strokeDashoffset = `${offset}`;
1302
1670
 
1303
- $('runs-count').textContent = formatNumber(state.runs.length);
1671
+ $('runs-count').textContent = resourceFailed('runs') && !state.runs.length ? 'n/a' : formatNumber(state.runs.length);
1304
1672
  const latestRun = state.runs[0];
1305
- $('latest-run-state').textContent = latestRun
1673
+ $('latest-run-state').textContent = resourceFailed('runs') && !latestRun
1674
+ ? 'Runs endpoint unavailable'
1675
+ : latestRun
1306
1676
  ? `${latestRun.state} · ${latestRun.selectedBackends?.memoryBackend || 'memory n/a'}`
1307
1677
  : 'No runs yet';
1308
1678
  $('artifact-count').textContent = `${state.skills.length} / ${state.workflows.length}`;
1309
- $('artifact-summary').textContent = `${state.skills.filter((item) => item.rolloutStatus === 'promoted').length} promoted skills · ${state.workflows.filter((item) => item.rolloutStatus === 'promoted').length} promoted workflows`;
1310
- $('docs-health').textContent = state.health.docs?.pagesWorkflowValid ? 'Healthy' : 'Needs attention';
1311
- $('ci-health').textContent = state.health.docs?.pagesWorkflowValid
1679
+ $('artifact-summary').textContent = resourceFailed('skills') || resourceFailed('workflows')
1680
+ ? 'Skill or workflow endpoint unavailable'
1681
+ : `${state.skills.filter((item) => item.rolloutStatus === 'promoted').length} promoted skills · ${state.workflows.filter((item) => item.rolloutStatus === 'promoted').length} promoted workflows`;
1682
+ $('docs-health').textContent = resourceFailed('health')
1683
+ ? 'Unknown'
1684
+ : state.health.docs?.pagesWorkflowValid ? 'Healthy' : 'Needs attention';
1685
+ $('ci-health').textContent = resourceFailed('health')
1686
+ ? 'Health endpoint unavailable'
1687
+ : state.health.docs?.pagesWorkflowValid
1312
1688
  ? `Pages valid · ${state.health.ci?.eventHistory || 0} events`
1313
1689
  : 'Pages syntax or docs deployment needs attention';
1314
1690
 
1315
1691
  const memory = state.health.memory || {};
1316
- $('memory-tier-summary').textContent = `${memory.cortex || 0} cortex`;
1692
+ $('memory-tier-summary').textContent = resourceFailed('health') ? 'health unavailable' : `${memory.cortex || 0} cortex`;
1317
1693
 
1318
1694
  const pod = state.pod || {};
1319
- $('pod-summary').textContent = `${(pod.activeWorkers || []).length} workers`;
1320
- $('pod-highlights').innerHTML = pod.activeWorkers?.length
1695
+ $('pod-summary').textContent = resourceFailed('pod') ? 'unavailable' : `${(pod.activeWorkers || []).length} workers`;
1696
+ $('pod-highlights').innerHTML = resourceFailed('pod') && !(pod.activeWorkers || []).length
1697
+ ? emptyState('POD endpoint unavailable.')
1698
+ : pod.activeWorkers?.length
1321
1699
  ? pod.activeWorkers.slice(0, 4).map((worker) => `
1322
1700
  <div class="card interactive ${state.selected?.kind === 'pod-worker' && state.selected.id === worker.workerId ? 'active' : ''}" data-kind="pod-worker" data-id="${escapeHtml(worker.workerId)}">
1323
1701
  <div class="card-title">
@@ -1328,7 +1706,13 @@
1328
1706
  <div class="list-inline">${(worker.tags || []).slice(0, 4).map((tag) => chip(tag)).join('')}</div>
1329
1707
  </div>
1330
1708
  `).join('')
1331
- : '<div class="empty">Waiting for POD traffic.</div>';
1709
+ : emptyState('Waiting for POD traffic.');
1710
+
1711
+ $('pod-highlights').querySelectorAll('.card.interactive').forEach((card) => {
1712
+ card.addEventListener('click', () => {
1713
+ void openEntity(card.getAttribute('data-kind'), card.getAttribute('data-id'));
1714
+ });
1715
+ });
1332
1716
  }
1333
1717
 
1334
1718
  function buildGraphModel() {
@@ -1374,7 +1758,14 @@
1374
1758
  return { nodes, links };
1375
1759
  }
1376
1760
 
1377
- const nodes = (state.memoryNetwork.nodes || []).map((node) => ({ ...node }));
1761
+ const nodes = (state.memoryNetwork.nodes || []).length
1762
+ ? (state.memoryNetwork.nodes || []).map((node) => ({ ...node }))
1763
+ : state.memories.slice(0, 18).map((memory) => ({
1764
+ id: memory.id,
1765
+ label: memory.excerpt,
1766
+ entityType: 'memory',
1767
+ tier: memory.tier,
1768
+ }));
1378
1769
  const links = (state.memoryNetwork.links || []).map((link) => ({ ...link }));
1379
1770
  return { nodes, links };
1380
1771
  }
@@ -1394,7 +1785,19 @@
1394
1785
  if (!model.nodes.length) {
1395
1786
  svg.innerHTML = '';
1396
1787
  empty.classList.remove('hidden');
1788
+ empty.textContent = state.graphMode === 'memory'
1789
+ ? resourceFailed('memory')
1790
+ ? 'Memory endpoint unavailable.'
1791
+ : 'No memory stored yet.'
1792
+ : state.graphMode === 'runs'
1793
+ ? resourceFailed('runs')
1794
+ ? 'Runs endpoint unavailable.'
1795
+ : 'No run graph data yet.'
1796
+ : resourceFailed('pod')
1797
+ ? 'POD endpoint unavailable.'
1798
+ : 'No POD topology data yet.';
1397
1799
  $('graph-stats').innerHTML = '';
1800
+ $('graph-note').textContent = empty.textContent;
1398
1801
  return;
1399
1802
  }
1400
1803
 
@@ -1469,6 +1872,9 @@
1469
1872
  state.graphMode === 'runs' ? chip(`${state.runs.length} recent runs`) : '',
1470
1873
  state.graphMode === 'pod' ? chip(`${(state.pod.activeWorkers || []).length} pod workers`) : '',
1471
1874
  ].filter(Boolean).join('');
1875
+ $('graph-note').textContent = state.graphMode === 'memory' && resourceFailed('memoryNetwork')
1876
+ ? 'Memory network endpoint unavailable. Showing snapshot fallback topology.'
1877
+ : config.subtitle;
1472
1878
  }
1473
1879
 
1474
1880
  function renderLibrary() {
@@ -1486,7 +1892,9 @@
1486
1892
  const container = $('library-list');
1487
1893
 
1488
1894
  if (state.libraryMode === 'memories') {
1489
- container.innerHTML = state.memories.length
1895
+ container.innerHTML = resourceFailed('memory') && !state.memories.length
1896
+ ? emptyState('Memory endpoint unavailable.')
1897
+ : state.memories.length
1490
1898
  ? state.memories.map((memory) => `
1491
1899
  <div class="card interactive ${state.selected?.kind === 'memory' && state.selected.id === memory.id ? 'active' : ''}" data-kind="memory" data-id="${escapeHtml(memory.id)}">
1492
1900
  <div class="card-title">
@@ -1497,9 +1905,11 @@
1497
1905
  <div class="list-inline">${(memory.tags || []).slice(0, 5).map((tag) => chip(tag)).join('')}</div>
1498
1906
  </div>
1499
1907
  `).join('')
1500
- : '<div class="empty">No memories yet.</div>';
1908
+ : emptyState('No memory stored yet.');
1501
1909
  } else if (state.libraryMode === 'skills') {
1502
- container.innerHTML = state.skills.length
1910
+ container.innerHTML = resourceFailed('skills') && !state.skills.length
1911
+ ? emptyState('Skills endpoint unavailable.')
1912
+ : state.skills.length
1503
1913
  ? state.skills.map((skill) => `
1504
1914
  <div class="card interactive ${state.selected?.kind === 'skill' && state.selected.id === skill.skillId ? 'active' : ''}" data-kind="skill" data-id="${escapeHtml(skill.skillId)}">
1505
1915
  <div class="card-title">
@@ -1514,9 +1924,11 @@
1514
1924
  </div>
1515
1925
  </div>
1516
1926
  `).join('')
1517
- : '<div class="empty">No skills loaded.</div>';
1927
+ : emptyState('No skills loaded.');
1518
1928
  } else if (state.libraryMode === 'workflows') {
1519
- container.innerHTML = state.workflows.length
1929
+ container.innerHTML = resourceFailed('workflows') && !state.workflows.length
1930
+ ? emptyState('Workflows endpoint unavailable.')
1931
+ : state.workflows.length
1520
1932
  ? state.workflows.map((workflow) => `
1521
1933
  <div class="card interactive ${state.selected?.kind === 'workflow' && state.selected.id === workflow.workflowId ? 'active' : ''}" data-kind="workflow" data-id="${escapeHtml(workflow.workflowId)}">
1522
1934
  <div class="card-title">
@@ -1527,9 +1939,11 @@
1527
1939
  <div class="list-inline">${chip(`domain:${workflow.domain}`)}${chip(`verify:${workflow.effectiveness?.verificationPasses || 0}`)}</div>
1528
1940
  </div>
1529
1941
  `).join('')
1530
- : '<div class="empty">No workflows loaded.</div>';
1942
+ : emptyState('No workflows loaded.');
1531
1943
  } else if (state.libraryMode === 'pod') {
1532
- container.innerHTML = state.pod.messages?.length
1944
+ container.innerHTML = resourceFailed('pod') && !(state.pod.messages || []).length
1945
+ ? emptyState('POD endpoint unavailable.')
1946
+ : state.pod.messages?.length
1533
1947
  ? state.pod.messages.map((message) => `
1534
1948
  <div class="card interactive ${state.selected?.kind === 'pod-worker' && state.selected.id === message.workerId ? 'active' : ''}" data-kind="pod-worker" data-id="${escapeHtml(message.workerId)}">
1535
1949
  <div class="card-title">
@@ -1541,9 +1955,11 @@
1541
1955
  <div class="list-inline">${(message.tags || []).map((tag) => chip(tag)).join('')}</div>
1542
1956
  </div>
1543
1957
  `).join('')
1544
- : '<div class="empty">No POD signals captured.</div>';
1958
+ : emptyState('No POD signals captured.');
1545
1959
  } else {
1546
- container.innerHTML = state.clients.length
1960
+ container.innerHTML = resourceFailed('clients') && !state.clients.length
1961
+ ? emptyState('Clients endpoint unavailable.')
1962
+ : state.clients.length
1547
1963
  ? state.clients.map((client) => `
1548
1964
  <div class="card interactive ${state.selected?.kind === 'client' && state.selected.id === client.clientId ? 'active' : ''}" data-kind="client" data-id="${escapeHtml(client.clientId)}">
1549
1965
  <div class="card-title">
@@ -1554,7 +1970,7 @@
1554
1970
  <div class="list-inline">${(client.evidence || []).map((evidence) => chip(evidence)).join('')}</div>
1555
1971
  </div>
1556
1972
  `).join('')
1557
- : '<div class="empty">No clients detected.</div>';
1973
+ : emptyState('No clients detected.');
1558
1974
  }
1559
1975
 
1560
1976
  container.querySelectorAll('.card.interactive').forEach((card) => {
@@ -1567,7 +1983,7 @@
1567
1983
  }
1568
1984
 
1569
1985
  function renderEvents() {
1570
- $('events-summary').textContent = `${state.events.length} signals`;
1986
+ $('events-summary').textContent = resourceFailed('events') ? 'events unavailable' : `${state.events.length} signals`;
1571
1987
  document.querySelectorAll('#event-filters button').forEach((button) => {
1572
1988
  button.classList.toggle('active', button.dataset.eventFilter === state.eventFilter);
1573
1989
  });
@@ -1593,13 +2009,17 @@
1593
2009
  </details>
1594
2010
  </article>
1595
2011
  `).join('')
1596
- : '<div class="empty">No events for this filter.</div>';
2012
+ : resourceFailed('events')
2013
+ ? emptyState('Events endpoint unavailable. Live stream may still populate this panel.')
2014
+ : emptyState('No events for this filter.');
1597
2015
  }
1598
2016
 
1599
2017
  function renderDrawer() {
1600
2018
  const drawer = $('drawer');
2019
+ const backdrop = $('drawer-backdrop');
1601
2020
  if (!state.selected || !state.selected.data) {
1602
2021
  drawer.classList.remove('open');
2022
+ backdrop.classList.remove('open');
1603
2023
  $('drawer-title').textContent = 'Inspector';
1604
2024
  $('drawer-subtitle').textContent = 'Select a node or card.';
1605
2025
  $('drawer-body').innerHTML = '<div class="empty">Touch a memory, run, workflow, skill, POD worker, or client to inspect it.</div>';
@@ -1607,6 +2027,7 @@
1607
2027
  }
1608
2028
 
1609
2029
  drawer.classList.add('open');
2030
+ backdrop.classList.add('open');
1610
2031
  const { kind, data } = state.selected;
1611
2032
  $('drawer-title').textContent = formatDrawerTitle(kind, data);
1612
2033
  $('drawer-subtitle').textContent = formatDrawerSubtitle(kind, data);
@@ -1900,29 +2321,33 @@
1900
2321
 
1901
2322
  async function openEntity(kind, id) {
1902
2323
  if (!kind || !id) return;
1903
- if (kind === 'memory') {
1904
- await refreshMemorySelection(id, true);
1905
- } else if (kind === 'run') {
1906
- const run = state.runs.find((item) => item.runId === id) || await fetchJson(`/api/runs/${encodeURIComponent(id)}`);
1907
- if (run?.error) return;
1908
- state.selected = { kind: 'run', id, data: run };
1909
- } else if (kind === 'skill') {
1910
- const skill = state.skills.find((item) => item.skillId === id);
1911
- if (!skill) return;
1912
- state.selected = { kind: 'skill', id, data: skill };
1913
- } else if (kind === 'workflow') {
1914
- const workflow = state.workflows.find((item) => item.workflowId === id);
1915
- if (!workflow) return;
1916
- state.selected = { kind: 'workflow', id, data: workflow };
1917
- } else if (kind === 'pod-worker') {
1918
- const worker = await fetchJson(`/api/pod/${encodeURIComponent(id)}`);
1919
- state.selected = { kind: 'pod-worker', id, data: worker };
1920
- } else if (kind === 'client') {
1921
- const client = state.clients.find((item) => item.clientId === id);
1922
- if (!client) return;
1923
- state.selected = { kind: 'client', id, data: client };
2324
+ try {
2325
+ if (kind === 'memory') {
2326
+ await refreshMemorySelection(id, true);
2327
+ } else if (kind === 'run') {
2328
+ const run = state.runs.find((item) => item.runId === id) || await fetchJson(`/api/runs/${encodeURIComponent(id)}`);
2329
+ if (run?.error) return;
2330
+ state.selected = { kind: 'run', id, data: run };
2331
+ } else if (kind === 'skill') {
2332
+ const skill = state.skills.find((item) => item.skillId === id);
2333
+ if (!skill) return;
2334
+ state.selected = { kind: 'skill', id, data: skill };
2335
+ } else if (kind === 'workflow') {
2336
+ const workflow = state.workflows.find((item) => item.workflowId === id);
2337
+ if (!workflow) return;
2338
+ state.selected = { kind: 'workflow', id, data: workflow };
2339
+ } else if (kind === 'pod-worker') {
2340
+ const worker = await fetchJson(`/api/pod/${encodeURIComponent(id)}`);
2341
+ state.selected = { kind: 'pod-worker', id, data: worker };
2342
+ } else if (kind === 'client') {
2343
+ const client = state.clients.find((item) => item.clientId === id);
2344
+ if (!client) return;
2345
+ state.selected = { kind: 'client', id, data: client };
2346
+ }
2347
+ render();
2348
+ } catch (error) {
2349
+ $('control-status').textContent = `Failed to load ${kind}: ${error.message || 'unknown error'}`;
1924
2350
  }
1925
- render();
1926
2351
  }
1927
2352
 
1928
2353
  function shortLabel(label) {
@@ -2043,6 +2468,18 @@
2043
2468
  render();
2044
2469
  });
2045
2470
 
2471
+ $('drawer-backdrop').addEventListener('click', () => {
2472
+ state.selected = null;
2473
+ render();
2474
+ });
2475
+
2476
+ document.addEventListener('keydown', (e) => {
2477
+ if (e.key === 'Escape' && state.selected) {
2478
+ state.selected = null;
2479
+ render();
2480
+ }
2481
+ });
2482
+
2046
2483
  document.querySelectorAll('#graph-modes button').forEach((button) => {
2047
2484
  button.addEventListener('click', async () => {
2048
2485
  state.graphMode = button.dataset.graphMode;
@@ -2065,24 +2502,40 @@
2065
2502
  });
2066
2503
  }
2067
2504
 
2505
+ let _stream = null;
2506
+ let _reconnectDelay = 1000;
2507
+ let _reconnectTimer = null;
2508
+
2068
2509
  function connectStream() {
2069
- const stream = new EventSource('/stream');
2070
- stream.onopen = () => {
2510
+ if (_stream) { _stream.close(); _stream = null; }
2511
+ if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
2512
+
2513
+ _stream = new EventSource('/stream');
2514
+ _stream.onopen = () => {
2071
2515
  state.streamConnected = true;
2516
+ _reconnectDelay = 1000;
2072
2517
  renderHeader();
2073
2518
  };
2074
- stream.onerror = () => {
2519
+ _stream.onerror = () => {
2075
2520
  state.streamConnected = false;
2076
2521
  renderHeader();
2522
+ if (_stream) { _stream.close(); _stream = null; }
2523
+ _reconnectTimer = setTimeout(connectStream, _reconnectDelay);
2524
+ _reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
2077
2525
  };
2078
- stream.onmessage = (event) => {
2526
+ _stream.onmessage = (event) => {
2079
2527
  try {
2080
- const payload = JSON.parse(event.data);
2081
- if (payload?.connected) return;
2082
- state.events.unshift(payload);
2528
+ const raw = JSON.parse(event.data);
2529
+ if (raw?.connected) return;
2530
+ const payload = normalizeEventCard(raw);
2531
+ if (!payload) return;
2532
+ if (!state.events.some((e) => e.id === payload.id)) {
2533
+ state.events.unshift(payload);
2534
+ }
2083
2535
  state.events = state.events.slice(0, 120);
2536
+ setResourceStatus('events', 'ready');
2084
2537
  if (['runtime', 'memory', 'pod', 'skills', 'workflows', 'clients'].includes(payload.category)) {
2085
- void refreshAll().catch(() => {});
2538
+ scheduleRefresh();
2086
2539
  } else {
2087
2540
  renderEvents();
2088
2541
  renderHeader();
@@ -2095,14 +2548,13 @@
2095
2548
 
2096
2549
  async function bootstrap() {
2097
2550
  attachStaticHandlers();
2098
- try {
2099
- await refreshAll();
2100
- } catch (error) {
2101
- $('control-status').textContent = `Initial load failed: ${error.message}`;
2102
- }
2551
+ await refreshAll();
2552
+ $('control-status').textContent = state.banner?.message
2553
+ ? state.banner.message
2554
+ : 'Dashboard actions stay local and route through the runtime.';
2103
2555
  connectStream();
2104
2556
  setInterval(() => {
2105
- void refreshAll().catch(() => {});
2557
+ scheduleRefresh();
2106
2558
  }, 20000);
2107
2559
  }
2108
2560