adonisjs-server-stats 1.2.1 → 1.3.0

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.
Files changed (95) hide show
  1. package/README.md +144 -9
  2. package/dist/src/dashboard/chart_aggregator.d.ts +21 -0
  3. package/dist/src/dashboard/chart_aggregator.d.ts.map +1 -0
  4. package/dist/src/dashboard/chart_aggregator.js +89 -0
  5. package/dist/src/dashboard/dashboard_controller.d.ts +147 -0
  6. package/dist/src/dashboard/dashboard_controller.d.ts.map +1 -0
  7. package/dist/src/dashboard/dashboard_controller.js +1008 -0
  8. package/dist/src/dashboard/dashboard_routes.d.ts +16 -0
  9. package/dist/src/dashboard/dashboard_routes.d.ts.map +1 -0
  10. package/dist/src/dashboard/dashboard_routes.js +88 -0
  11. package/dist/src/dashboard/dashboard_store.d.ts +158 -0
  12. package/dist/src/dashboard/dashboard_store.d.ts.map +1 -0
  13. package/dist/src/dashboard/dashboard_store.js +723 -0
  14. package/dist/src/dashboard/integrations/cache_inspector.d.ts +88 -0
  15. package/dist/src/dashboard/integrations/cache_inspector.d.ts.map +1 -0
  16. package/dist/src/dashboard/integrations/cache_inspector.js +215 -0
  17. package/dist/src/dashboard/integrations/config_inspector.d.ts +33 -0
  18. package/dist/src/dashboard/integrations/config_inspector.d.ts.map +1 -0
  19. package/dist/src/dashboard/integrations/config_inspector.js +155 -0
  20. package/dist/src/dashboard/integrations/index.d.ts +7 -0
  21. package/dist/src/dashboard/integrations/index.d.ts.map +1 -0
  22. package/dist/src/dashboard/integrations/index.js +3 -0
  23. package/dist/src/dashboard/integrations/queue_inspector.d.ts +106 -0
  24. package/dist/src/dashboard/integrations/queue_inspector.d.ts.map +1 -0
  25. package/dist/src/dashboard/integrations/queue_inspector.js +182 -0
  26. package/dist/src/dashboard/migrator.d.ts +18 -0
  27. package/dist/src/dashboard/migrator.d.ts.map +1 -0
  28. package/dist/src/dashboard/migrator.js +144 -0
  29. package/dist/src/dashboard/models/stats_email.d.ts +19 -0
  30. package/dist/src/dashboard/models/stats_email.d.ts.map +1 -0
  31. package/dist/src/dashboard/models/stats_email.js +66 -0
  32. package/dist/src/dashboard/models/stats_event.d.ts +14 -0
  33. package/dist/src/dashboard/models/stats_event.d.ts.map +1 -0
  34. package/dist/src/dashboard/models/stats_event.js +43 -0
  35. package/dist/src/dashboard/models/stats_log.d.ts +12 -0
  36. package/dist/src/dashboard/models/stats_log.d.ts.map +1 -0
  37. package/dist/src/dashboard/models/stats_log.js +42 -0
  38. package/dist/src/dashboard/models/stats_metric.d.ts +15 -0
  39. package/dist/src/dashboard/models/stats_metric.d.ts.map +1 -0
  40. package/dist/src/dashboard/models/stats_metric.js +50 -0
  41. package/dist/src/dashboard/models/stats_query.d.ts +20 -0
  42. package/dist/src/dashboard/models/stats_query.d.ts.map +1 -0
  43. package/dist/src/dashboard/models/stats_query.js +67 -0
  44. package/dist/src/dashboard/models/stats_request.d.ts +21 -0
  45. package/dist/src/dashboard/models/stats_request.d.ts.map +1 -0
  46. package/dist/src/dashboard/models/stats_request.js +61 -0
  47. package/dist/src/dashboard/models/stats_saved_filter.d.ts +11 -0
  48. package/dist/src/dashboard/models/stats_saved_filter.d.ts.map +1 -0
  49. package/dist/src/dashboard/models/stats_saved_filter.js +38 -0
  50. package/dist/src/dashboard/models/stats_trace.d.ts +19 -0
  51. package/dist/src/dashboard/models/stats_trace.d.ts.map +1 -0
  52. package/dist/src/dashboard/models/stats_trace.js +67 -0
  53. package/dist/src/debug/debug_store.d.ts +5 -0
  54. package/dist/src/debug/debug_store.d.ts.map +1 -1
  55. package/dist/src/debug/debug_store.js +10 -0
  56. package/dist/src/debug/email_collector.d.ts +2 -0
  57. package/dist/src/debug/email_collector.d.ts.map +1 -1
  58. package/dist/src/debug/email_collector.js +4 -0
  59. package/dist/src/debug/event_collector.d.ts +2 -0
  60. package/dist/src/debug/event_collector.d.ts.map +1 -1
  61. package/dist/src/debug/event_collector.js +11 -2
  62. package/dist/src/debug/query_collector.d.ts +2 -0
  63. package/dist/src/debug/query_collector.d.ts.map +1 -1
  64. package/dist/src/debug/query_collector.js +11 -0
  65. package/dist/src/debug/ring_buffer.d.ts +3 -0
  66. package/dist/src/debug/ring_buffer.d.ts.map +1 -1
  67. package/dist/src/debug/ring_buffer.js +6 -0
  68. package/dist/src/debug/trace_collector.d.ts +4 -2
  69. package/dist/src/debug/trace_collector.d.ts.map +1 -1
  70. package/dist/src/debug/trace_collector.js +7 -2
  71. package/dist/src/debug/types.d.ts +8 -0
  72. package/dist/src/debug/types.d.ts.map +1 -1
  73. package/dist/src/edge/client/dashboard.css +1504 -0
  74. package/dist/src/edge/client/dashboard.js +2378 -0
  75. package/dist/src/edge/client/debug-panel.css +530 -110
  76. package/dist/src/edge/client/debug-panel.js +663 -22
  77. package/dist/src/edge/client/stats-bar.css +115 -41
  78. package/dist/src/edge/client/stats-bar.js +37 -3
  79. package/dist/src/edge/plugin.d.ts.map +1 -1
  80. package/dist/src/edge/plugin.js +21 -0
  81. package/dist/src/edge/views/dashboard.edge +382 -0
  82. package/dist/src/edge/views/debug-panel.edge +60 -14
  83. package/dist/src/edge/views/stats-bar.edge +9 -0
  84. package/dist/src/index.d.ts +2 -0
  85. package/dist/src/index.d.ts.map +1 -1
  86. package/dist/src/index.js +1 -0
  87. package/dist/src/middleware/request_tracking_middleware.d.ts +20 -0
  88. package/dist/src/middleware/request_tracking_middleware.d.ts.map +1 -1
  89. package/dist/src/middleware/request_tracking_middleware.js +66 -2
  90. package/dist/src/provider/server_stats_provider.d.ts +13 -0
  91. package/dist/src/provider/server_stats_provider.d.ts.map +1 -1
  92. package/dist/src/provider/server_stats_provider.js +175 -1
  93. package/dist/src/types.d.ts +42 -0
  94. package/dist/src/types.d.ts.map +1 -1
  95. package/package.json +14 -1
@@ -16,9 +16,59 @@
16
16
 
17
17
  if (!panel || !wrench) return;
18
18
 
19
+ // ── Theme detection & toggle ────────────────────────────────────
20
+ let themeOverride = localStorage.getItem('ss-dash-theme');
21
+ const themeBtn = document.getElementById('ss-dbg-theme-btn');
22
+
23
+ const applyPanelTheme = () => {
24
+ if (themeOverride) {
25
+ panel.setAttribute('data-ss-theme', themeOverride);
26
+ } else {
27
+ panel.removeAttribute('data-ss-theme');
28
+ }
29
+ if (themeBtn) {
30
+ const isDark = themeOverride === 'dark' || (!themeOverride && window.matchMedia('(prefers-color-scheme: dark)').matches);
31
+ themeBtn.textContent = isDark ? '\u2600' : '\u263D';
32
+ themeBtn.title = isDark ? 'Switch to light theme' : 'Switch to dark theme';
33
+ }
34
+ };
35
+
36
+ if (themeBtn) {
37
+ themeBtn.addEventListener('click', function () {
38
+ const isDark = themeOverride === 'dark' || (!themeOverride && window.matchMedia('(prefers-color-scheme: dark)').matches);
39
+ themeOverride = isDark ? 'light' : 'dark';
40
+ localStorage.setItem('ss-dash-theme', themeOverride);
41
+ applyPanelTheme();
42
+ // Sync stats bar if applyBarTheme exists globally
43
+ if (typeof window.__ssApplyBarTheme === 'function') window.__ssApplyBarTheme();
44
+ });
45
+ }
46
+
47
+ applyPanelTheme();
48
+
49
+ // Listen for cross-tab theme changes
50
+ window.addEventListener('storage', function (e) {
51
+ if (e.key === 'ss-dash-theme') {
52
+ themeOverride = e.newValue;
53
+ applyPanelTheme();
54
+ }
55
+ });
56
+
19
57
  const LOGS_ENDPOINT = panel.dataset.logsEndpoint || (BASE + '/logs');
20
58
 
21
59
  const tracingEnabled = panel.dataset.tracing === '1';
60
+ const dashboardPath = panel.dataset.dashboardPath || null;
61
+ const DASH_API = dashboardPath ? (dashboardPath.replace(/\/+$/, '') + '/api') : null;
62
+
63
+ /** Build an SVG external-link icon for deep links. */
64
+ const deepLinkSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
65
+
66
+ /** Build a deep link anchor element HTML string. */
67
+ const deepLink = (section, id) => {
68
+ if (!dashboardPath) return '';
69
+ const href = dashboardPath + '#' + section + (id != null ? '?id=' + id : '');
70
+ return ' <a href="' + esc(href) + '" target="_blank" class="ss-dbg-deeplink" title="Open in dashboard" onclick="event.stopPropagation()">' + deepLinkSvg + '</a>';
71
+ };
22
72
 
23
73
  let isOpen = false;
24
74
  let activeTab = tracingEnabled ? 'timeline' : 'queries';
@@ -27,6 +77,8 @@
27
77
  let logFilter = 'all';
28
78
  let cachedLogs = [];
29
79
  const currentPath = window.location.pathname;
80
+ let isLive = false;
81
+ let transmitSub = null;
30
82
 
31
83
  // ── Helpers ──────────────────────────────────────────────────────
32
84
  const esc = (s) => {
@@ -92,7 +144,7 @@
92
144
 
93
145
  // ── Custom pane cell formatter ────────────────────────────────────
94
146
  const formatCell = (value, col) => {
95
- if (value === null || value === undefined) return '<span style="color:#525252">-</span>';
147
+ if (value === null || value === undefined) return '<span class="ss-dbg-c-dim">-</span>';
96
148
  const fmt = col.format || 'text';
97
149
  switch (fmt) {
98
150
  case 'time':
@@ -177,6 +229,7 @@
177
229
  panel.querySelectorAll('.ss-dbg-pane').forEach((p) => p.classList.remove('ss-dbg-active'));
178
230
 
179
231
  tab.classList.add('ss-dbg-active');
232
+ tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
180
233
  const pane = document.getElementById('ss-dbg-pane-' + name);
181
234
  if (pane) pane.classList.add('ss-dbg-active');
182
235
 
@@ -193,6 +246,9 @@
193
246
  else if (name === 'routes' && !fetched.routes) fetchRoutes();
194
247
  else if (name === 'logs') fetchLogs();
195
248
  else if (name === 'emails') fetchEmails();
249
+ else if (name === 'cache') fetchCache();
250
+ else if (name === 'jobs') fetchJobs();
251
+ else if (name === 'config' && !fetched.config) fetchConfig();
196
252
  else {
197
253
  const cp = customPanes.find((p) => p.id === name);
198
254
  if (cp) {
@@ -264,7 +320,7 @@
264
320
  }
265
321
 
266
322
  let html = '<table class="ss-dbg-table"><thead><tr>'
267
- + '<th style="width:40px">#</th>'
323
+ + '<th style="width:64px">#</th>'
268
324
  + '<th>SQL</th>'
269
325
  + '<th style="width:70px">Duration</th>'
270
326
  + '<th style="width:60px">Method</th>'
@@ -277,13 +333,13 @@
277
333
  const durClass = durationClass(q.duration);
278
334
  const dupCount = sqlCounts[q.sql] || 1;
279
335
  html += '<tr>'
280
- + '<td style="color:#525252">' + q.id + '</td>'
336
+ + '<td class="ss-dbg-c-dim" style="white-space:nowrap">' + q.id + deepLink('queries', q.id) + '</td>'
281
337
  + '<td><span class="ss-dbg-sql" title="Click to expand" onclick="this.classList.toggle(\'ss-dbg-expanded\')">' + esc(q.sql) + '</span>'
282
338
  + (dupCount > 1 ? ' <span class="ss-dbg-dup">x' + dupCount + '</span>' : '')
283
339
  + '</td>'
284
340
  + '<td class="ss-dbg-duration ' + durClass + '">' + q.duration.toFixed(2) + 'ms</td>'
285
341
  + '<td><span class="' + methodClass(q.method) + '">' + esc(q.method) + '</span></td>'
286
- + '<td style="color:#737373">' + esc(q.model || '-') + '</td>'
342
+ + '<td class="ss-dbg-c-muted">' + esc(q.model || '-') + '</td>'
287
343
  + '<td class="ss-dbg-event-time">' + timeAgo(q.timestamp) + '</td>'
288
344
  + '</tr>';
289
345
  }
@@ -340,7 +396,7 @@
340
396
  }
341
397
 
342
398
  let html = '<table class="ss-dbg-table"><thead><tr>'
343
- + '<th style="width:40px">#</th>'
399
+ + '<th style="width:64px">#</th>'
344
400
  + '<th>Event</th>'
345
401
  + '<th>Data</th>'
346
402
  + '<th style="width:100px">Time</th>'
@@ -351,14 +407,14 @@
351
407
  const hasData = ev.data && ev.data !== '-';
352
408
  const preview = hasData ? eventPreview(ev.data) : '-';
353
409
  html += '<tr>'
354
- + '<td style="color:#525252">' + ev.id + '</td>'
410
+ + '<td class="ss-dbg-c-dim" style="white-space:nowrap">' + ev.id + deepLink('events', ev.id) + '</td>'
355
411
  + '<td class="ss-dbg-event-name">' + esc(ev.event) + '</td>'
356
412
  + '<td class="ss-dbg-event-data">'
357
413
  + (hasData
358
414
  ? '<span class="ss-dbg-data-preview" data-ev-idx="' + i + '">' + esc(preview) + '</span>'
359
415
  + '<pre class="ss-dbg-data-full" id="ss-dbg-evdata-' + i + '" style="display:none">' + esc(ev.data) + '</pre>'
360
416
  + '<button type="button" class="ss-dbg-copy-btn" data-copy-idx="' + i + '" title="Copy JSON">&#x2398;</button>'
361
- : '<span style="color:#525252">-</span>')
417
+ : '<span class="ss-dbg-c-dim">-</span>')
362
418
  + '</td>'
363
419
  + '<td class="ss-dbg-event-time">' + formatTime(ev.timestamp) + '</td>'
364
420
  + '</tr>';
@@ -468,9 +524,9 @@
468
524
  html += '<tr' + (isCurrent ? ' class="ss-dbg-current-route"' : '') + '>'
469
525
  + '<td><span class="' + methodClass(r.method) + '">' + esc(r.method) + '</span></td>'
470
526
  + '<td>' + esc(r.pattern) + '</td>'
471
- + '<td style="color:#737373">' + esc(r.name || '-') + '</td>'
472
- + '<td style="color:#93c5fd">' + esc(r.handler) + '</td>'
473
- + '<td style="color:#525252;font-size:10px">' + (r.middleware.length ? esc(r.middleware.join(', ')) : '-') + '</td>'
527
+ + '<td class="ss-dbg-c-muted">' + esc(r.name || '-') + '</td>'
528
+ + '<td class="ss-dbg-c-sql">' + esc(r.handler) + '</td>'
529
+ + '<td class="ss-dbg-c-dim" style="font-size:10px">' + (r.middleware.length ? esc(r.middleware.join(', ')) : '-') + '</td>'
474
530
  + '</tr>';
475
531
  }
476
532
 
@@ -631,7 +687,7 @@
631
687
  }
632
688
 
633
689
  let html = '<table class="ss-dbg-table"><thead><tr>'
634
- + '<th style="width:40px">#</th>'
690
+ + '<th style="width:64px">#</th>'
635
691
  + '<th style="width:160px">From</th>'
636
692
  + '<th style="width:160px">To</th>'
637
693
  + '<th>Subject</th>'
@@ -644,13 +700,13 @@
644
700
  for (let i = 0; i < filtered.length; i++) {
645
701
  const e = filtered[i];
646
702
  html += '<tr class="ss-dbg-email-row" data-email-id="' + e.id + '">'
647
- + '<td style="color:#525252">' + e.id + '</td>'
648
- + '<td style="color:#a3a3a3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px" title="' + esc(e.from) + '">' + esc(e.from) + '</td>'
649
- + '<td style="color:#a3a3a3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px" title="' + esc(e.to) + '">' + esc(e.to) + '</td>'
650
- + '<td style="color:#93c5fd;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(e.subject) + '</td>'
703
+ + '<td class="ss-dbg-c-dim" style="white-space:nowrap">' + e.id + deepLink('emails', e.id) + '</td>'
704
+ + '<td class="ss-dbg-c-secondary" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px" title="' + esc(e.from) + '">' + esc(e.from) + '</td>'
705
+ + '<td class="ss-dbg-c-secondary" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px" title="' + esc(e.to) + '">' + esc(e.to) + '</td>'
706
+ + '<td class="ss-dbg-c-sql" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(e.subject) + '</td>'
651
707
  + '<td><span class="ss-dbg-email-status ss-dbg-email-status-' + esc(e.status) + '">' + esc(e.status) + '</span></td>'
652
- + '<td style="color:#737373">' + esc(e.mailer) + '</td>'
653
- + '<td style="color:#525252;text-align:center">' + (e.attachmentCount > 0 ? e.attachmentCount : '-') + '</td>'
708
+ + '<td class="ss-dbg-c-muted">' + esc(e.mailer) + '</td>'
709
+ + '<td class="ss-dbg-c-dim" style="text-align:center">' + (e.attachmentCount > 0 ? e.attachmentCount : '-') + '</td>'
654
710
  + '<td class="ss-dbg-event-time">' + timeAgo(e.timestamp) + '</td>'
655
711
  + '</tr>';
656
712
  }
@@ -753,7 +809,7 @@
753
809
  }
754
810
 
755
811
  let html = '<table class="ss-dbg-table"><thead><tr>'
756
- + '<th style="width:40px">#</th>'
812
+ + '<th style="width:64px">#</th>'
757
813
  + '<th style="width:60px">Method</th>'
758
814
  + '<th>URL</th>'
759
815
  + '<th style="width:55px">Status</th>'
@@ -766,13 +822,13 @@
766
822
  for (let i = 0; i < filtered.length; i++) {
767
823
  const t = filtered[i];
768
824
  html += '<tr class="ss-dbg-email-row" data-trace-id="' + t.id + '">'
769
- + '<td style="color:#525252">' + t.id + '</td>'
825
+ + '<td class="ss-dbg-c-dim" style="white-space:nowrap">' + t.id + deepLink('traces', t.id) + '</td>'
770
826
  + '<td><span class="' + methodClass(t.method) + '">' + esc(t.method) + '</span></td>'
771
827
  + '<td style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:300px" title="' + esc(t.url) + '">' + esc(t.url) + '</td>'
772
828
  + '<td><span class="ss-dbg-status ' + statusClass(t.statusCode) + '">' + t.statusCode + '</span></td>'
773
829
  + '<td class="ss-dbg-duration ' + durationClass(t.totalDuration) + '">' + t.totalDuration.toFixed(1) + 'ms</td>'
774
- + '<td style="color:#737373;text-align:center">' + t.spanCount + '</td>'
775
- + '<td style="text-align:center">' + (t.warningCount > 0 ? '<span style="color:#fbbf24">' + t.warningCount + '</span>' : '<span style="color:#333">-</span>') + '</td>'
830
+ + '<td class="ss-dbg-c-muted" style="text-align:center">' + t.spanCount + '</td>'
831
+ + '<td style="text-align:center">' + (t.warningCount > 0 ? '<span class="ss-dbg-c-amber">' + t.warningCount + '</span>' : '<span class="ss-dbg-c-border">-</span>') + '</td>'
776
832
  + '<td class="ss-dbg-event-time">' + timeAgo(t.timestamp) + '</td>'
777
833
  + '</tr>';
778
834
  }
@@ -888,6 +944,493 @@
888
944
 
889
945
  if (tlSearchInput) tlSearchInput.addEventListener('input', renderTraces);
890
946
 
947
+ // ── Mini Stats Bar ─────────────────────────────────────────────
948
+ const miniStatsEl = document.getElementById('ss-dbg-mini-stats');
949
+ let miniStatsTimer = null;
950
+
951
+ const fetchMiniStats = () => {
952
+ if (!DASH_API || !miniStatsEl) return;
953
+ fetchJSON(DASH_API + '/overview?range=1h')
954
+ .then((data) => {
955
+ const avg = data.avgResponseTime || 0;
956
+ const err = data.errorRate || 0;
957
+ const rpm = data.requestsPerMinute || 0;
958
+ const hasData = (data.totalRequests || 0) > 0;
959
+
960
+ if (!hasData) {
961
+ miniStatsEl.innerHTML = '';
962
+ return;
963
+ }
964
+
965
+ const avgClass = avg > 500 ? 'ss-dbg-stat-red' : avg > 200 ? 'ss-dbg-stat-amber' : 'ss-dbg-stat-green';
966
+ const errClass = err > 5 ? 'ss-dbg-stat-red' : err > 1 ? 'ss-dbg-stat-amber' : 'ss-dbg-stat-green';
967
+
968
+ miniStatsEl.innerHTML =
969
+ '<span class="ss-dbg-mini-stat"><span class="ss-dbg-mini-stat-value ' + avgClass + '">' + avg.toFixed(1) + 'ms</span> avg</span>'
970
+ + '<span class="ss-dbg-mini-stat"><span class="ss-dbg-mini-stat-value ' + errClass + '">' + err.toFixed(1) + '%</span> err</span>'
971
+ + '<span class="ss-dbg-mini-stat"><span class="ss-dbg-mini-stat-value">' + Math.round(rpm) + '</span> req/m</span>';
972
+ })
973
+ .catch(() => {
974
+ miniStatsEl.innerHTML = '';
975
+ });
976
+ };
977
+
978
+ // ── Cache Tab ─────────────────────────────────────────────────
979
+ const cacheSearchInput = document.getElementById('ss-dbg-search-cache');
980
+ const cacheSummaryEl = document.getElementById('ss-dbg-cache-summary');
981
+ const cacheBodyEl = document.getElementById('ss-dbg-cache-body');
982
+ const cacheStatsArea = document.getElementById('ss-dbg-cache-stats-area');
983
+ let cachedCacheData = { stats: {}, keys: [] };
984
+
985
+ const fetchCache = () => {
986
+ if (!DASH_API) return;
987
+ fetchJSON(DASH_API + '/cache')
988
+ .then((data) => {
989
+ cachedCacheData = data;
990
+ renderCache();
991
+ })
992
+ .catch(() => {
993
+ if (cacheBodyEl) cacheBodyEl.innerHTML = '<div class="ss-dbg-empty">Cache not available</div>';
994
+ if (cacheStatsArea) cacheStatsArea.innerHTML = '';
995
+ });
996
+ };
997
+
998
+ const renderCache = () => {
999
+ if (!cacheBodyEl) return;
1000
+ const stats = cachedCacheData.stats || {};
1001
+ const keys = cachedCacheData.keys || cachedCacheData.data || [];
1002
+ const filter = (cacheSearchInput ? cacheSearchInput.value : '').toLowerCase();
1003
+
1004
+ // Stats area
1005
+ if (cacheStatsArea) {
1006
+ cacheStatsArea.innerHTML =
1007
+ '<div class="ss-dbg-cache-stat"><span class="ss-dbg-cache-stat-label">Hit Rate:</span><span class="ss-dbg-cache-stat-value">' + (stats.hitRate || 0).toFixed(1) + '%</span></div>'
1008
+ + '<div class="ss-dbg-cache-stat"><span class="ss-dbg-cache-stat-label">Hits:</span><span class="ss-dbg-cache-stat-value">' + (stats.hits || 0) + '</span></div>'
1009
+ + '<div class="ss-dbg-cache-stat"><span class="ss-dbg-cache-stat-label">Misses:</span><span class="ss-dbg-cache-stat-value">' + (stats.misses || 0) + '</span></div>'
1010
+ + '<div class="ss-dbg-cache-stat"><span class="ss-dbg-cache-stat-label">Keys:</span><span class="ss-dbg-cache-stat-value">' + (stats.keyCount || keys.length || 0) + '</span></div>';
1011
+ }
1012
+
1013
+ if (cacheSummaryEl) {
1014
+ cacheSummaryEl.textContent = (stats.keyCount || keys.length || 0) + ' keys';
1015
+ }
1016
+
1017
+ let filtered = keys;
1018
+ if (filter) {
1019
+ filtered = keys.filter((k) => (k.key || '').toLowerCase().indexOf(filter) !== -1);
1020
+ }
1021
+
1022
+ if (filtered.length === 0) {
1023
+ cacheBodyEl.innerHTML = '<div class="ss-dbg-empty">' + (filter ? 'No matching cache keys' : 'No cache keys found') + '</div>';
1024
+ return;
1025
+ }
1026
+
1027
+ let html = '<table class="ss-dbg-table"><thead><tr>'
1028
+ + '<th>Key</th>'
1029
+ + '<th style="width:80px">Type</th>'
1030
+ + '<th style="width:80px">TTL</th>'
1031
+ + '<th style="width:80px">Size</th>'
1032
+ + '</tr></thead><tbody>';
1033
+
1034
+ for (let i = 0; i < filtered.length; i++) {
1035
+ const k = filtered[i];
1036
+ html += '<tr class="ss-dbg-email-row" data-cache-key="' + esc(k.key || '') + '">'
1037
+ + '<td class="ss-dbg-c-sql">' + esc(k.key || '') + '</td>'
1038
+ + '<td class="ss-dbg-c-muted">' + esc(k.type || '-') + '</td>'
1039
+ + '<td class="ss-dbg-c-muted">' + (k.ttl != null ? k.ttl + 's' : '-') + '</td>'
1040
+ + '<td class="ss-dbg-c-dim">' + (k.size != null ? k.size + 'B' : '-') + '</td>'
1041
+ + '</tr>';
1042
+ }
1043
+
1044
+ html += '</tbody></table>';
1045
+ cacheBodyEl.innerHTML = html;
1046
+
1047
+ // Click row to show cache detail
1048
+ cacheBodyEl.querySelectorAll('[data-cache-key]').forEach((row) => {
1049
+ row.addEventListener('click', () => {
1050
+ const key = row.getAttribute('data-cache-key');
1051
+ fetchJSON(DASH_API + '/cache/' + encodeURIComponent(key))
1052
+ .then((data) => {
1053
+ cacheBodyEl.innerHTML = '<div class="ss-dbg-cache-detail">'
1054
+ + '<button type="button" class="ss-dbg-btn-clear" id="ss-dbg-cache-back">&larr; Back</button>'
1055
+ + '&nbsp;&nbsp;<strong>' + esc(key) + '</strong>'
1056
+ + '<pre>' + esc(JSON.stringify(data.value || data, null, 2)) + '</pre>'
1057
+ + '</div>';
1058
+ const backBtn = document.getElementById('ss-dbg-cache-back');
1059
+ if (backBtn) backBtn.addEventListener('click', () => renderCache());
1060
+ })
1061
+ .catch(() => { /* ignore */ });
1062
+ });
1063
+ });
1064
+ };
1065
+
1066
+ if (cacheSearchInput) cacheSearchInput.addEventListener('input', renderCache);
1067
+
1068
+ // ── Jobs Tab ──────────────────────────────────────────────────
1069
+ const jobsBodyEl = document.getElementById('ss-dbg-jobs-body');
1070
+ const jobsSummaryEl = document.getElementById('ss-dbg-jobs-summary');
1071
+ const jobsStatsArea = document.getElementById('ss-dbg-jobs-stats-area');
1072
+ const jobFilters = panel.querySelectorAll('[data-ss-dbg-job-status]');
1073
+ let jobStatusFilter = 'all';
1074
+ let cachedJobsData = { data: [], stats: {} };
1075
+
1076
+ const fetchJobs = () => {
1077
+ if (!DASH_API) return;
1078
+ let url = DASH_API + '/jobs?limit=100';
1079
+ if (jobStatusFilter && jobStatusFilter !== 'all') url += '&status=' + jobStatusFilter;
1080
+
1081
+ fetchJSON(url)
1082
+ .then((data) => {
1083
+ cachedJobsData = data;
1084
+ renderJobs();
1085
+ })
1086
+ .catch(() => {
1087
+ if (jobsBodyEl) jobsBodyEl.innerHTML = '<div class="ss-dbg-empty">Jobs/Queue not available</div>';
1088
+ if (jobsStatsArea) jobsStatsArea.innerHTML = '';
1089
+ });
1090
+ };
1091
+
1092
+ const renderJobs = () => {
1093
+ if (!jobsBodyEl) return;
1094
+ const items = cachedJobsData.data || cachedJobsData.jobs || [];
1095
+ const stats = cachedJobsData.stats || {};
1096
+
1097
+ // Stats area
1098
+ if (jobsStatsArea) {
1099
+ jobsStatsArea.innerHTML = '<div class="ss-dbg-job-stats">'
1100
+ + '<div class="ss-dbg-job-stat"><span class="ss-dbg-job-stat-label">Active:</span><span class="ss-dbg-job-stat-value">' + (stats.active || 0) + '</span></div>'
1101
+ + '<div class="ss-dbg-job-stat"><span class="ss-dbg-job-stat-label">Waiting:</span><span class="ss-dbg-job-stat-value">' + (stats.waiting || 0) + '</span></div>'
1102
+ + '<div class="ss-dbg-job-stat"><span class="ss-dbg-job-stat-label">Delayed:</span><span class="ss-dbg-job-stat-value">' + (stats.delayed || 0) + '</span></div>'
1103
+ + '<div class="ss-dbg-job-stat"><span class="ss-dbg-job-stat-label">Completed:</span><span class="ss-dbg-job-stat-value">' + (stats.completed || 0) + '</span></div>'
1104
+ + '<div class="ss-dbg-job-stat"><span class="ss-dbg-job-stat-label">Failed:</span><span class="ss-dbg-job-stat-value ss-dbg-c-red">' + (stats.failed || 0) + '</span></div>'
1105
+ + '</div>';
1106
+ }
1107
+
1108
+ if (jobsSummaryEl) {
1109
+ const total = (cachedJobsData.meta ? cachedJobsData.meta.total : null) || items.length;
1110
+ jobsSummaryEl.textContent = total + ' jobs';
1111
+ }
1112
+
1113
+ if (items.length === 0) {
1114
+ jobsBodyEl.innerHTML = '<div class="ss-dbg-empty">No jobs found</div>';
1115
+ return;
1116
+ }
1117
+
1118
+ let html = '<table class="ss-dbg-table"><thead><tr>'
1119
+ + '<th style="width:50px">ID</th>'
1120
+ + '<th>Name</th>'
1121
+ + '<th style="width:80px">Status</th>'
1122
+ + '<th style="width:60px">Attempts</th>'
1123
+ + '<th style="width:80px">Duration</th>'
1124
+ + '<th style="width:70px">Time</th>'
1125
+ + '<th style="width:50px"></th>'
1126
+ + '</tr></thead><tbody>';
1127
+
1128
+ for (let i = 0; i < items.length; i++) {
1129
+ const j = items[i];
1130
+ const statusBadge = j.status === 'failed' ? 'red' : j.status === 'completed' ? 'green' : j.status === 'active' ? 'blue' : 'amber';
1131
+ html += '<tr>'
1132
+ + '<td class="ss-dbg-c-dim">' + j.id + '</td>'
1133
+ + '<td class="ss-dbg-c-sql">' + esc(j.name || '') + '</td>'
1134
+ + '<td><span class="ss-dbg-badge ss-dbg-badge-' + statusBadge + '">' + esc(j.status || '') + '</span></td>'
1135
+ + '<td class="ss-dbg-c-muted" style="text-align:center">' + (j.attempts || j.attemptsMade || 0) + '</td>'
1136
+ + '<td class="ss-dbg-duration">' + (j.duration != null ? j.duration.toFixed(0) + 'ms' : '-') + '</td>'
1137
+ + '<td class="ss-dbg-event-time">' + timeAgo(j.timestamp || j.processedOn || j.created_at) + '</td>'
1138
+ + '<td>' + (j.status === 'failed' ? '<button class="ss-dbg-retry-btn" data-retry-id="' + j.id + '">Retry</button>' : '') + '</td>'
1139
+ + '</tr>';
1140
+ }
1141
+
1142
+ html += '</tbody></table>';
1143
+ jobsBodyEl.innerHTML = html;
1144
+
1145
+ // Retry buttons
1146
+ jobsBodyEl.querySelectorAll('.ss-dbg-retry-btn').forEach((btn) => {
1147
+ btn.addEventListener('click', (e) => {
1148
+ e.stopPropagation();
1149
+ const id = btn.getAttribute('data-retry-id');
1150
+ btn.textContent = '...';
1151
+ btn.disabled = true;
1152
+ fetch(DASH_API + '/jobs/' + id + '/retry', { method: 'POST', credentials: 'same-origin' })
1153
+ .then(() => { btn.textContent = 'OK'; setTimeout(fetchJobs, 1000); })
1154
+ .catch(() => { btn.textContent = 'Retry'; btn.disabled = false; });
1155
+ });
1156
+ });
1157
+ };
1158
+
1159
+ jobFilters.forEach((btn) => {
1160
+ btn.addEventListener('click', () => {
1161
+ jobFilters.forEach((b) => b.classList.remove('ss-dbg-active'));
1162
+ btn.classList.add('ss-dbg-active');
1163
+ jobStatusFilter = btn.getAttribute('data-ss-dbg-job-status');
1164
+ fetchJobs();
1165
+ });
1166
+ });
1167
+
1168
+ // ── Config Tab ────────────────────────────────────────────────
1169
+ const configBodyEl = document.getElementById('ss-dbg-config-body');
1170
+ const configSummaryEl = document.getElementById('ss-dbg-config-summary');
1171
+ const configSearchInput = document.getElementById('ss-dbg-search-config');
1172
+ const configTabs = panel.querySelectorAll('[data-ss-dbg-config-tab]');
1173
+ let configRawData = null;
1174
+ let configActiveTab = 'config';
1175
+ let configSearchTerm = '';
1176
+
1177
+ const flattenConfig = (obj, prefix) => {
1178
+ const results = [];
1179
+ if (typeof obj !== 'object' || obj === null) {
1180
+ results.push({ path: prefix, value: obj });
1181
+ return results;
1182
+ }
1183
+ const keys = Object.keys(obj);
1184
+ for (let i = 0; i < keys.length; i++) {
1185
+ const fullPath = prefix ? prefix + '.' + keys[i] : keys[i];
1186
+ const val = obj[keys[i]];
1187
+ if (typeof val === 'object' && val !== null && !Array.isArray(val) && !val.__redacted) {
1188
+ const nested = flattenConfig(val, fullPath);
1189
+ for (let n = 0; n < nested.length; n++) results.push(nested[n]);
1190
+ } else {
1191
+ results.push({ path: fullPath, value: val });
1192
+ }
1193
+ }
1194
+ return results;
1195
+ };
1196
+
1197
+ const countLeaves = (obj) => {
1198
+ if (typeof obj !== 'object' || obj === null || obj.__redacted) return 1;
1199
+ let count = 0;
1200
+ const keys = Object.keys(obj);
1201
+ for (let i = 0; i < keys.length; i++) count += countLeaves(obj[keys[i]]);
1202
+ return count;
1203
+ };
1204
+
1205
+ const formatConfigValue = (val) => {
1206
+ if (val === null || val === undefined) return '<span class="ss-dbg-config-val-null">null</span>';
1207
+ if (val === true) return '<span class="ss-dbg-config-val-true">true</span>';
1208
+ if (val === false) return '<span class="ss-dbg-config-val-false">false</span>';
1209
+ if (typeof val === 'number') return '<span class="ss-dbg-config-val-number">' + val + '</span>';
1210
+ if (Array.isArray(val)) {
1211
+ const items = val.map((item) => {
1212
+ if (item === null || item === undefined) return 'null';
1213
+ if (typeof item === 'object') {
1214
+ try { return JSON.stringify(item); } catch { return String(item); }
1215
+ }
1216
+ return String(item);
1217
+ });
1218
+ return '<span class="ss-dbg-config-val-array">[' + esc(items.join(', ')) + ']</span>';
1219
+ }
1220
+ if (typeof val === 'object') {
1221
+ try { return '<span class="ss-dbg-config-val-null">' + esc(JSON.stringify(val, null, 2)) + '</span>'; } catch { /* fall through */ }
1222
+ }
1223
+ return esc(String(val));
1224
+ };
1225
+
1226
+ const highlightMatch = (text, term) => {
1227
+ if (!term) return text;
1228
+ const idx = text.toLowerCase().indexOf(term.toLowerCase());
1229
+ if (idx === -1) return text;
1230
+ return text.slice(0, idx) + '<mark class="ss-dbg-config-match">' + text.slice(idx, idx + term.length) + '</mark>' + text.slice(idx + term.length);
1231
+ };
1232
+
1233
+ const isRedactedObj = (val) => val && typeof val === 'object' && val.__redacted === true;
1234
+
1235
+ const renderRedacted = (val, prefix) => {
1236
+ const cls = prefix + '-config-redacted';
1237
+ const realVal = esc(val.value || '');
1238
+ return '<span class="' + cls + ' ' + prefix + '-redacted-wrap" data-redacted-value="' + realVal + '">'
1239
+ + '<span class="' + prefix + '-redacted-display">' + esc(val.display) + '</span>'
1240
+ + '<span class="' + prefix + '-redacted-real" style="display:none">' + realVal + '</span>'
1241
+ + '<button type="button" class="' + prefix + '-redacted-reveal" title="Reveal value">'
1242
+ + '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
1243
+ + '</button>'
1244
+ + '<button type="button" class="' + prefix + '-redacted-copy" title="Copy value">'
1245
+ + '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
1246
+ + '</button>'
1247
+ + '</span>';
1248
+ };
1249
+
1250
+ const bindRedactedButtons = (container, prefix) => {
1251
+ container.querySelectorAll('.' + prefix + '-redacted-reveal').forEach((btn) => {
1252
+ btn.addEventListener('click', (e) => {
1253
+ e.stopPropagation();
1254
+ const wrap = btn.closest('.' + prefix + '-redacted-wrap');
1255
+ if (!wrap) return;
1256
+ const display = wrap.querySelector('.' + prefix + '-redacted-display');
1257
+ const real = wrap.querySelector('.' + prefix + '-redacted-real');
1258
+ if (!display || !real) return;
1259
+ const isHidden = real.style.display === 'none';
1260
+ display.style.display = isHidden ? 'none' : '';
1261
+ real.style.display = isHidden ? '' : 'none';
1262
+ btn.innerHTML = isHidden
1263
+ ? '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>'
1264
+ : '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
1265
+ btn.title = isHidden ? 'Hide value' : 'Reveal value';
1266
+ });
1267
+ });
1268
+
1269
+ container.querySelectorAll('.' + prefix + '-redacted-copy').forEach((btn) => {
1270
+ btn.addEventListener('click', (e) => {
1271
+ e.stopPropagation();
1272
+ const wrap = btn.closest('.' + prefix + '-redacted-wrap');
1273
+ if (!wrap) return;
1274
+ const val = wrap.getAttribute('data-redacted-value');
1275
+ if (!val) return;
1276
+ navigator.clipboard.writeText(val).then(() => {
1277
+ btn.innerHTML = '\u2713';
1278
+ setTimeout(() => {
1279
+ btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
1280
+ }, 1200);
1281
+ });
1282
+ });
1283
+ });
1284
+ };
1285
+
1286
+ const fetchConfig = () => {
1287
+ if (!DASH_API) return;
1288
+ fetchJSON(DASH_API + '/config')
1289
+ .then((data) => {
1290
+ configRawData = data;
1291
+ fetched.config = true;
1292
+ renderConfig();
1293
+ })
1294
+ .catch(() => {
1295
+ if (configBodyEl) configBodyEl.innerHTML = '<div class="ss-dbg-empty">Config not available</div>';
1296
+ });
1297
+ };
1298
+
1299
+ const renderConfigTable = (obj, prefix) => {
1300
+ const flat = flattenConfig(obj, prefix);
1301
+ let html = '<table class="ss-dbg-table"><thead><tr>'
1302
+ + '<th style="width:320px">Key</th><th>Value</th>'
1303
+ + '</tr></thead><tbody>';
1304
+ for (let i = 0; i < flat.length; i++) {
1305
+ const item = flat[i];
1306
+ const relPath = item.path.indexOf(prefix + '.') === 0 ? item.path.slice(prefix.length + 1) : item.path;
1307
+ const redacted = isRedactedObj(item.value);
1308
+ html += '<tr>'
1309
+ + '<td><span class="ss-dbg-config-key">' + esc(relPath) + '</span></td>'
1310
+ + '<td>' + (redacted ? renderRedacted(item.value, 'ss-dbg') : '<span class="ss-dbg-config-val">' + formatConfigValue(item.value) + '</span>') + '</td>'
1311
+ + '</tr>';
1312
+ }
1313
+ html += '</tbody></table>';
1314
+ return html;
1315
+ };
1316
+
1317
+ const renderConfig = () => {
1318
+ if (!configBodyEl || !configRawData) return;
1319
+
1320
+ const source = configActiveTab === 'env' ? (configRawData.env || {}) : (configRawData.config || configRawData);
1321
+ const flat = flattenConfig(source, '');
1322
+ const term = configSearchTerm.toLowerCase();
1323
+ let filtered = flat;
1324
+ if (term) {
1325
+ filtered = flat.filter((item) => {
1326
+ var valStr = isRedactedObj(item.value) ? item.value.display : String(item.value);
1327
+ return item.path.toLowerCase().indexOf(term) !== -1 || valStr.toLowerCase().indexOf(term) !== -1;
1328
+ });
1329
+ }
1330
+
1331
+ if (configSummaryEl) {
1332
+ configSummaryEl.textContent = filtered.length + (term ? ' of ' + flat.length : '') + ' entries';
1333
+ }
1334
+
1335
+ let html = '';
1336
+
1337
+ if (configActiveTab === 'env') {
1338
+ // Env vars: simple table
1339
+ html += '<div class="ss-dbg-config-table-wrap"><table class="ss-dbg-table"><thead><tr>'
1340
+ + '<th>Variable</th><th>Value</th>'
1341
+ + '</tr></thead><tbody>';
1342
+ for (let i = 0; i < filtered.length; i++) {
1343
+ const item = filtered[i];
1344
+ const redacted = isRedactedObj(item.value);
1345
+ const displayVal = redacted ? item.value.display : String(item.value);
1346
+ html += '<tr>'
1347
+ + '<td><span class="ss-dbg-config-key">' + highlightMatch(esc(item.path), term) + '</span></td>'
1348
+ + '<td>' + (redacted ? renderRedacted(item.value, 'ss-dbg') : '<span class="ss-dbg-config-val">' + highlightMatch(esc(displayVal), term) + '</span>') + '</td>'
1349
+ + '</tr>';
1350
+ }
1351
+ html += '</tbody></table></div>';
1352
+ } else {
1353
+ if (term) {
1354
+ // Search mode: flat list
1355
+ html += '<div class="ss-dbg-config-table-wrap"><table class="ss-dbg-table"><thead><tr>'
1356
+ + '<th>Path</th><th>Value</th>'
1357
+ + '</tr></thead><tbody>';
1358
+ for (let i = 0; i < filtered.length; i++) {
1359
+ const item = filtered[i];
1360
+ const redacted = isRedactedObj(item.value);
1361
+ const displayVal = redacted ? item.value.display : String(item.value);
1362
+ html += '<tr>'
1363
+ + '<td><span class="ss-dbg-config-key" style="white-space:nowrap">' + highlightMatch(esc(item.path), term) + '</span></td>'
1364
+ + '<td>' + (redacted ? renderRedacted(item.value, 'ss-dbg') : '<span class="ss-dbg-config-val" style="word-break:break-all">' + highlightMatch(esc(displayVal), term) + '</span>') + '</td>'
1365
+ + '</tr>';
1366
+ }
1367
+ html += '</tbody></table></div>';
1368
+ } else {
1369
+ // Browse mode: collapsible sections
1370
+ const topKeys = Object.keys(source);
1371
+ html += '<div class="ss-dbg-config-sections">';
1372
+ for (let t = 0; t < topKeys.length; t++) {
1373
+ const sectionKey = topKeys[t];
1374
+ const sectionVal = source[sectionKey];
1375
+ const childCount = countLeaves(sectionVal);
1376
+ const isObj = typeof sectionVal === 'object' && sectionVal !== null && !sectionVal.__redacted;
1377
+
1378
+ html += '<div class="ss-dbg-config-section">';
1379
+ if (isObj) {
1380
+ html += '<div class="ss-dbg-config-section-header" data-config-section="' + esc(sectionKey) + '">'
1381
+ + '<span class="ss-dbg-config-toggle">\u25B6</span>'
1382
+ + '<span class="ss-dbg-config-key">' + esc(sectionKey) + '</span>'
1383
+ + '<span class="ss-dbg-config-count">' + childCount + ' entries</span>'
1384
+ + '</div>';
1385
+ html += '<div class="ss-dbg-config-section-body" style="display:none">';
1386
+ html += renderConfigTable(sectionVal, sectionKey);
1387
+ html += '</div>';
1388
+ } else {
1389
+ html += '<div class="ss-dbg-config-section-header ss-dbg-config-leaf">'
1390
+ + '<span class="ss-dbg-config-key">' + esc(sectionKey) + '</span>'
1391
+ + '<span class="ss-dbg-config-val" style="margin-left:8px">' + formatConfigValue(sectionVal) + '</span>'
1392
+ + '</div>';
1393
+ }
1394
+ html += '</div>';
1395
+ }
1396
+ html += '</div>';
1397
+ }
1398
+ }
1399
+
1400
+ configBodyEl.innerHTML = html;
1401
+
1402
+ // Bind section toggles
1403
+ configBodyEl.querySelectorAll('[data-config-section]').forEach((header) => {
1404
+ header.addEventListener('click', () => {
1405
+ const sectionBody = header.nextElementSibling;
1406
+ if (!sectionBody) return;
1407
+ const isHidden = sectionBody.style.display === 'none';
1408
+ sectionBody.style.display = isHidden ? '' : 'none';
1409
+ const toggle = header.querySelector('.ss-dbg-config-toggle');
1410
+ if (toggle) toggle.textContent = isHidden ? '\u25BC' : '\u25B6';
1411
+ });
1412
+ });
1413
+
1414
+ // Bind redacted reveal/copy buttons
1415
+ bindRedactedButtons(configBodyEl, 'ss-dbg');
1416
+ };
1417
+
1418
+ configTabs.forEach((btn) => {
1419
+ btn.addEventListener('click', () => {
1420
+ configTabs.forEach((b) => b.classList.remove('ss-dbg-active'));
1421
+ btn.classList.add('ss-dbg-active');
1422
+ configActiveTab = btn.getAttribute('data-ss-dbg-config-tab');
1423
+ renderConfig();
1424
+ });
1425
+ });
1426
+
1427
+ if (configSearchInput) {
1428
+ configSearchInput.addEventListener('input', () => {
1429
+ configSearchTerm = configSearchInput.value.trim();
1430
+ renderConfig();
1431
+ });
1432
+ }
1433
+
891
1434
  // ── Custom panes: fetch, render, bind ───────────────────────────
892
1435
  const getNestedValue = (obj, path) => {
893
1436
  const parts = path.split('.');
@@ -1008,13 +1551,34 @@
1008
1551
  }
1009
1552
  }
1010
1553
 
1554
+ // ── Connection mode indicator ──────────────────────────────────
1555
+ const POLL_INTERVAL_NORMAL = REFRESH_INTERVAL;
1556
+ const POLL_INTERVAL_LIVE = 15000; // slow polling as fallback when live
1557
+
1558
+ const updateConnectionIndicator = () => {
1559
+ const el = document.getElementById('ss-dbg-conn-mode');
1560
+ if (!el) return;
1561
+ if (isLive) {
1562
+ el.textContent = 'live';
1563
+ el.className = 'ss-dbg-conn-mode ss-dbg-conn-live';
1564
+ el.title = 'Connected via Transmit (SSE) — real-time updates';
1565
+ } else {
1566
+ el.textContent = 'polling';
1567
+ el.className = 'ss-dbg-conn-mode ss-dbg-conn-polling';
1568
+ el.title = 'Polling every ' + (POLL_INTERVAL_NORMAL / 1000) + 's';
1569
+ }
1570
+ };
1571
+
1011
1572
  // ── Auto-refresh ────────────────────────────────────────────────
1012
1573
  const startRefresh = () => {
1013
1574
  stopRefresh();
1575
+ fetchMiniStats();
1576
+ const interval = isLive ? POLL_INTERVAL_LIVE : POLL_INTERVAL_NORMAL;
1014
1577
  refreshTimer = setInterval(() => {
1015
1578
  if (!isOpen) return;
1016
1579
  loadTab(activeTab);
1017
- }, REFRESH_INTERVAL);
1580
+ fetchMiniStats();
1581
+ }, interval);
1018
1582
  };
1019
1583
 
1020
1584
  const stopRefresh = () => {
@@ -1024,6 +1588,83 @@
1024
1588
  }
1025
1589
  };
1026
1590
 
1591
+ // ── Transmit (SSE) support ─────────────────────────────────────
1592
+ const initTransmit = () => {
1593
+ // window.Transmit is set by the inline IIFE injected before this module
1594
+ const TransmitClass = (typeof window !== 'undefined' && window.Transmit) ? window.Transmit : null;
1595
+
1596
+ if (!TransmitClass) return; // Transmit client not available
1597
+
1598
+ try {
1599
+ const transmit = new TransmitClass({
1600
+ baseUrl: window.location.origin,
1601
+ onSubscription: () => {
1602
+ isLive = true;
1603
+ updateConnectionIndicator();
1604
+ // Restart refresh with slower interval now that we have live updates
1605
+ if (isOpen) startRefresh();
1606
+ },
1607
+ onReconnectFailed: () => {
1608
+ isLive = false;
1609
+ updateConnectionIndicator();
1610
+ if (isOpen) startRefresh();
1611
+ },
1612
+ onSubscribeFailed: () => {
1613
+ isLive = false;
1614
+ updateConnectionIndicator();
1615
+ }
1616
+ });
1617
+
1618
+ transmitSub = transmit.subscription('server-stats/debug');
1619
+
1620
+ transmitSub.onMessage((message) => {
1621
+ try {
1622
+ const event = typeof message === 'string' ? JSON.parse(message) : message;
1623
+ handleLiveEvent(event);
1624
+ } catch { /* ignore */ }
1625
+ });
1626
+
1627
+ transmitSub.create().catch(() => {
1628
+ isLive = false;
1629
+ updateConnectionIndicator();
1630
+ });
1631
+ } catch {
1632
+ // Transmit init failed — stay on polling
1633
+ }
1634
+ };
1635
+
1636
+ const handleLiveEvent = (event) => {
1637
+ if (!isOpen) return;
1638
+
1639
+ // Backend sends { types: ['query', 'event', ...] }
1640
+ const types = event.types || (event.type ? [event.type] : []);
1641
+ const tabMap = {
1642
+ 'query': 'queries',
1643
+ 'event': 'events',
1644
+ 'email': 'emails',
1645
+ 'trace': 'timeline'
1646
+ };
1647
+
1648
+ let shouldRefresh = false;
1649
+ for (let i = 0; i < types.length; i++) {
1650
+ const targetTab = tabMap[types[i]];
1651
+ if (targetTab && targetTab === activeTab) {
1652
+ shouldRefresh = true;
1653
+ }
1654
+ if (types[i] === 'query') {
1655
+ updateBarQueryBadge();
1656
+ }
1657
+ }
1658
+
1659
+ if (shouldRefresh) {
1660
+ loadTab(activeTab);
1661
+ }
1662
+ };
1663
+
1664
+ // Initialize Transmit after a short delay to let the page fully load
1665
+ setTimeout(initTransmit, 500);
1666
+ updateConnectionIndicator();
1667
+
1027
1668
  // ── Stats bar query badge (always visible) ──────────────────────
1028
1669
  const updateBarQueryBadge = () => {
1029
1670
  const el = document.getElementById('ss-b-dbg-queries');