bgrun 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,10 @@
1
- /** @jsxImportSource melina/client */
2
1
  /**
3
2
  * bgrun Dashboard — Page Client Interactivity
4
3
  *
5
4
  * NOT a React component. A mount function that adds interactivity
6
- * to the server-rendered HTML. JSX here creates real DOM elements
7
- * (via Melina's jsx-dom runtime, not React virtual DOM).
8
- *
9
- * Log viewer uses Melina's VDOM render() with keyed reconciler
10
- * for efficient incremental DOM updates.
5
+ * to the server-rendered HTML. JSX creates real DOM elements via
6
+ * Melina's jsx-dom runtime (mapped from react/jsx-runtime).
11
7
  */
12
- import { render as melinaRender, createElement as h, setReconciler } from 'melina/client/render';
13
8
 
14
9
  interface ProcessData {
15
10
  name: string;
@@ -293,6 +288,7 @@ function ProcessCard({ p }: { p: ProcessData }) {
293
288
  <div className="card-header">
294
289
  <div className="process-name">
295
290
  <span>{p.name}</span>
291
+ {p.group && <span className="group-badge" title={`Group: ${p.group}`}>{p.group}</span>}
296
292
  {guarded && <span className="guard-badge" title="Auto-restart enabled">🛡️</span>}
297
293
  </div>
298
294
  <span className={`status-badge ${p.running ? 'running' : 'stopped'}`}>
@@ -418,6 +414,8 @@ export default function mount(): () => void {
418
414
  let isFirstLoad = true;
419
415
  let allProcesses: ProcessData[] = [];
420
416
  let searchQuery = '';
417
+ let groupQuery = '';
418
+ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
421
419
  let collapsedGroups: Set<string> = new Set(JSON.parse(localStorage.getItem('bgr_collapsed_groups') || '[]'));
422
420
  let drawerProcess: string | null = null;
423
421
  let drawerTab: 'stdout' | 'stderr' = 'stdout';
@@ -433,6 +431,28 @@ export default function mount(): () => void {
433
431
  let logLastSize = -1; // Detect no-change polls
434
432
  let logNeedsFullRebuild = true; // Full DOM rebuild flag (on tab switch, search change)
435
433
 
434
+ // ─── Virtual Scrolling State ───
435
+ let LOG_LINE_HEIGHT = 22; // default estimate, auto-calibrated on first render
436
+ let logLineHeightCalibrated = false;
437
+ const LOG_OVERSCAN = 10; // extra lines rendered above/below viewport
438
+ const VIRTUAL_THRESHOLD = 200; // switch to virtual mode above this many lines
439
+ let logVirtualActive = false; // whether virtual scrolling is engaged
440
+ let logFilteredIndices: number[] = []; // indices into logLinesRaw that pass the search filter
441
+ let logScrollRAF: number | null = null; // rAF handle for throttled scroll
442
+
443
+ /** Measure actual log line height from DOM on first render */
444
+ function calibrateLogLineHeight(logsEl: HTMLElement) {
445
+ if (logLineHeightCalibrated) return;
446
+ const firstLine = logsEl.querySelector('.log-line') as HTMLElement;
447
+ if (firstLine) {
448
+ const measured = firstLine.getBoundingClientRect().height;
449
+ if (measured > 0) {
450
+ LOG_LINE_HEIGHT = Math.round(measured);
451
+ logLineHeightCalibrated = true;
452
+ }
453
+ }
454
+ }
455
+
436
456
  // ─── Version Badge ───
437
457
  const versionBadge = $('version-badge');
438
458
  async function loadVersion() {
@@ -447,6 +467,48 @@ export default function mount(): () => void {
447
467
  }
448
468
  loadVersion();
449
469
 
470
+ // ─── Guard Activity Feed ───
471
+ interface GuardEvent {
472
+ time: number;
473
+ name: string;
474
+ action: string;
475
+ success: boolean;
476
+ }
477
+
478
+ async function loadGuardEvents() {
479
+ const listEl = $('guard-activity-list');
480
+ const emptyEl = $('guard-activity-empty');
481
+ if (!listEl) return;
482
+ try {
483
+ const res = await fetch('/api/guard-events');
484
+ const events: GuardEvent[] = await res.json();
485
+ if (events.length === 0) {
486
+ if (emptyEl) emptyEl.style.display = '';
487
+ listEl.innerHTML = '';
488
+ return;
489
+ }
490
+ if (emptyEl) emptyEl.style.display = 'none';
491
+ listEl.replaceChildren(...events.slice(0, 10).map(ev => {
492
+ const date = new Date(ev.time);
493
+ const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
494
+ const icon = ev.success ? '↻' : '✕';
495
+ const actionText = ev.action === 'restart' ? 'restarted' : ev.action;
496
+ return (
497
+ <div className={`guard-event ${ev.success ? 'success' : 'failed'}`}>
498
+ <span className="guard-event-time">{timeStr}</span>
499
+ <span className="guard-event-icon">{icon}</span>
500
+ <span className="guard-event-name">{ev.name}</span>
501
+ <span className="guard-event-action">{actionText}</span>
502
+ </div>
503
+ ) as unknown as Node;
504
+ }));
505
+ } catch {
506
+ if (emptyEl) emptyEl.style.display = '';
507
+ }
508
+ }
509
+ loadGuardEvents();
510
+ setInterval(loadGuardEvents, 10000); // Refresh every 10s
511
+
450
512
  // ─── Load & Render Processes ───
451
513
 
452
514
  async function loadProcesses() {
@@ -455,24 +517,67 @@ export default function mount(): () => void {
455
517
  try {
456
518
  const res = await fetch('/api/processes');
457
519
  allProcesses = await res.json();
520
+ updateGroupFilter();
458
521
  renderFilteredProcesses();
459
522
  updateStats(allProcesses);
460
- } catch {
461
- // silently retry on next tick
523
+ } catch (err) {
524
+ console.error('[bgr-dashboard] loadProcesses error:', err);
462
525
  } finally {
463
526
  isFetching = false;
464
527
  }
465
528
  }
466
529
 
530
+ function updateGroupFilter() {
531
+ const groupFilter = $('group-filter') as HTMLSelectElement;
532
+ if (!groupFilter) return;
533
+ const groups = new Set<string>();
534
+ for (const p of allProcesses) {
535
+ if (p.group) groups.add(p.group);
536
+ }
537
+ const currentValue = groupFilter.value;
538
+ groupFilter.replaceChildren(
539
+ <option value="">All Groups</option> as unknown as Node,
540
+ ...Array.from(groups).sort().map(g => <option value={g}>{g}</option> as unknown as Node)
541
+ );
542
+ // Preserve selection if still valid
543
+ if (currentValue && groups.has(currentValue)) {
544
+ groupFilter.value = currentValue;
545
+ }
546
+ }
547
+
467
548
  function renderFilteredProcesses() {
468
- const filtered = searchQuery
549
+ // Always sync searchQuery from DOM to prevent desync
550
+ if (searchInput && searchInput.value.toLowerCase().trim() !== searchQuery) {
551
+ searchQuery = searchInput.value.toLowerCase().trim();
552
+ }
553
+ // Sync groupQuery from dropdown
554
+ const groupFilter = $('group-filter') as HTMLSelectElement;
555
+ if (groupFilter && groupFilter.value !== groupQuery) {
556
+ groupQuery = groupFilter.value;
557
+ }
558
+ let filtered = searchQuery
469
559
  ? allProcesses.filter(p =>
470
560
  p.name.toLowerCase().includes(searchQuery) ||
471
561
  p.command.toLowerCase().includes(searchQuery) ||
472
562
  (p.port && String(p.port).includes(searchQuery))
473
563
  )
474
564
  : allProcesses;
565
+ // Apply group filter
566
+ if (groupQuery) {
567
+ filtered = filtered.filter(p => p.group === groupQuery);
568
+ }
475
569
  renderProcesses(filtered);
570
+
571
+ // Update search result count badge
572
+ const badge = $('search-count');
573
+ if (badge) {
574
+ if (searchQuery) {
575
+ badge.textContent = `${filtered.length}/${allProcesses.length}`;
576
+ badge.style.display = 'inline-block';
577
+ } else {
578
+ badge.style.display = 'none';
579
+ }
580
+ }
476
581
  }
477
582
 
478
583
  function updateStats(processes: ProcessData[]) {
@@ -526,6 +631,16 @@ export default function mount(): () => void {
526
631
  }
527
632
  }
528
633
 
634
+ function toggleGroup(groupDir: string) {
635
+ if (collapsedGroups.has(groupDir)) {
636
+ collapsedGroups.delete(groupDir);
637
+ } else {
638
+ collapsedGroups.add(groupDir);
639
+ }
640
+ localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
641
+ renderFilteredProcesses();
642
+ }
643
+
529
644
  function renderProcesses(processes: ProcessData[]) {
530
645
  const tbody = $('processes-table');
531
646
  const cardsEl = $('mobile-cards');
@@ -553,61 +668,72 @@ export default function mount(): () => void {
553
668
  groups[key].push(p);
554
669
  });
555
670
 
556
- const nodes: Node[] = [];
557
671
  const sortedGroupKeys = Object.keys(groups).sort();
558
672
 
559
- // Always show group headers for every directory
673
+ // Build DOM nodes for table rows
674
+ const rows: Node[] = [];
560
675
  sortedGroupKeys.forEach(groupDir => {
561
676
  const procs = groups[groupDir];
562
677
  const running = procs.filter(p => p.running).length;
563
678
  const collapsed = collapsedGroups.has(groupDir);
564
- nodes.push(<GroupHeader name={groupDir} running={running} total={procs.length} collapsed={collapsed} /> as unknown as Node);
679
+ rows.push(<GroupHeader name={groupDir} running={running} total={procs.length} collapsed={collapsed} /> as unknown as Node);
565
680
  if (!collapsed) {
566
681
  procs.forEach(p => {
567
- nodes.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
682
+ rows.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
568
683
  });
569
684
  }
570
685
  });
571
686
 
572
- tbody.replaceChildren(...nodes);
687
+ // Replace tbody contents with new DOM nodes
688
+ tbody.replaceChildren(...rows);
573
689
 
574
690
  // Add click handlers for group headers (toggle collapse)
575
691
  tbody.querySelectorAll('.group-header').forEach(header => {
576
692
  header.addEventListener('click', (e: Event) => {
577
- // Don't collapse if clicking action buttons
578
693
  if ((e.target as Element).closest('[data-action]')) return;
579
694
  const groupName = (header as HTMLElement).dataset.groupName;
580
695
  if (!groupName) return;
581
- if (collapsedGroups.has(groupName)) {
582
- collapsedGroups.delete(groupName);
583
- } else {
584
- collapsedGroups.add(groupName);
585
- }
586
- localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
587
- renderFilteredProcesses();
696
+ toggleGroup(groupName);
588
697
  });
589
698
  });
590
699
 
591
700
  // Render mobile cards
592
701
  if (cardsEl) {
593
- const cards = processes.map(p => <ProcessCard p={p} /> as unknown as Node);
594
- cardsEl.replaceChildren(...cards);
702
+ cardsEl.replaceChildren(...processes.map(p => <ProcessCard p={p} /> as unknown as Node));
595
703
  }
596
704
 
597
705
  if (isFirstLoad) isFirstLoad = false;
598
706
 
599
- // Highlight selected row
707
+ // Restore selected row + keyboard focus row
600
708
  if (drawerProcess) {
601
- const row = tbody.querySelector(`tr[data-process-name="${drawerProcess}"]`);
709
+ const finalTbody = $('processes-table') || tbody;
710
+ const row = finalTbody.querySelector(`tr[data-process-name="${drawerProcess}"]`);
602
711
  if (row) row.classList.add('selected');
603
712
  }
713
+ // Restore keyboard focus ring if user had a row focused
714
+ if (focusedProcessName) {
715
+ const finalTbody = $('processes-table') || tbody;
716
+ const focusRow = finalTbody.querySelector(`tr[data-process-name="${focusedProcessName}"]`);
717
+ if (focusRow) focusRow.classList.add('focus-ring');
718
+ }
604
719
  }
605
720
 
606
- // ─── Search ───
721
+ // ─── Search (debounced 150ms) ───
607
722
 
608
723
  const searchInput = $('search-input') as HTMLInputElement;
609
724
  searchInput?.addEventListener('input', () => {
610
- searchQuery = searchInput.value.toLowerCase().trim();
725
+ if (searchDebounce) clearTimeout(searchDebounce);
726
+ searchDebounce = setTimeout(() => {
727
+ searchQuery = searchInput.value.toLowerCase().trim();
728
+ renderFilteredProcesses();
729
+ }, 150);
730
+ });
731
+
732
+ // ─── Group Filter ───
733
+
734
+ const groupFilter = $('group-filter') as HTMLSelectElement;
735
+ groupFilter?.addEventListener('change', () => {
736
+ groupQuery = groupFilter.value;
611
737
  renderFilteredProcesses();
612
738
  });
613
739
 
@@ -925,6 +1051,8 @@ export default function mount(): () => void {
925
1051
  logCurrentTab = '';
926
1052
  logLastSize = -1;
927
1053
  logNeedsFullRebuild = true;
1054
+ logVirtualActive = false;
1055
+ logFilteredIndices = [];
928
1056
  if (!skipRefresh) refreshDrawerLogs();
929
1057
  }
930
1058
 
@@ -1124,8 +1252,70 @@ export default function mount(): () => void {
1124
1252
  tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
1125
1253
  }
1126
1254
 
1127
- // Use keyed reconciler for efficient log line diffing
1128
- setReconciler('keyed');
1255
+
1256
+ // ─── Build filtered indices ───
1257
+ function rebuildFilteredIndices() {
1258
+ const search = logSearch.toLowerCase();
1259
+ logFilteredIndices = [];
1260
+ for (let i = 0; i < logLinesRaw.length; i++) {
1261
+ if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1262
+ logFilteredIndices.push(i);
1263
+ }
1264
+ }
1265
+
1266
+ // ─── Render a single log line HTML string ───
1267
+ function renderLogLineHtml(rawIndex: number): string {
1268
+ const num = rawIndex + 1;
1269
+ return `<div class="log-line" data-ln="${num}"><span class="log-line-num">${num}</span><span class="log-line-content">${logLinesHtml[rawIndex]}</span></div>`;
1270
+ }
1271
+
1272
+ // ─── Virtual scroll: render only visible slice ───
1273
+ function virtualRenderSlice(logsEl: HTMLElement) {
1274
+ const count = logFilteredIndices.length;
1275
+ if (count === 0) {
1276
+ logsEl.innerHTML = '<em style="color: var(--text-muted)">No logs available</em>';
1277
+ return;
1278
+ }
1279
+
1280
+ const totalHeight = count * LOG_LINE_HEIGHT;
1281
+ const scrollTop = logsEl.scrollTop;
1282
+ const viewportH = logsEl.clientHeight;
1283
+
1284
+ // Calculate visible range with overscan
1285
+ let startIdx = Math.floor(scrollTop / LOG_LINE_HEIGHT) - LOG_OVERSCAN;
1286
+ let endIdx = Math.ceil((scrollTop + viewportH) / LOG_LINE_HEIGHT) + LOG_OVERSCAN;
1287
+ startIdx = Math.max(0, startIdx);
1288
+ endIdx = Math.min(count - 1, endIdx);
1289
+
1290
+ // Only rebuild if the visible range actually changed
1291
+ const topSpacer = logsEl.querySelector('.log-virtual-top') as HTMLElement;
1292
+ if (topSpacer && topSpacer.dataset.start === String(startIdx) && topSpacer.dataset.end === String(endIdx)) {
1293
+ return; // same range, skip DOM work
1294
+ }
1295
+
1296
+ const topH = startIdx * LOG_LINE_HEIGHT;
1297
+ const bottomH = Math.max(0, (count - endIdx - 1) * LOG_LINE_HEIGHT);
1298
+
1299
+ // Build visible lines
1300
+ const chunks: string[] = [];
1301
+ chunks.push(`<div class="log-virtual-top" data-start="${startIdx}" data-end="${endIdx}" style="height:${topH}px"></div>`);
1302
+ for (let i = startIdx; i <= endIdx; i++) {
1303
+ chunks.push(renderLogLineHtml(logFilteredIndices[i]));
1304
+ }
1305
+ chunks.push(`<div class="log-virtual-bottom" style="height:${bottomH}px"></div>`);
1306
+ logsEl.innerHTML = chunks.join('');
1307
+ }
1308
+
1309
+ // ─── Scroll handler for virtual mode ───
1310
+ function onLogScroll() {
1311
+ if (!logVirtualActive) return;
1312
+ if (logScrollRAF) return; // already scheduled
1313
+ logScrollRAF = requestAnimationFrame(() => {
1314
+ logScrollRAF = null;
1315
+ const logsEl = $('drawer-logs') as HTMLElement;
1316
+ if (logsEl) virtualRenderSlice(logsEl);
1317
+ });
1318
+ }
1129
1319
 
1130
1320
  function fullRebuildLogs(logsEl: HTMLElement) {
1131
1321
  const search = logSearch.toLowerCase();
@@ -1133,48 +1323,78 @@ export default function mount(): () => void {
1133
1323
  logsEl.innerHTML = '<em style="color: var(--text-muted)">No logs available</em>';
1134
1324
  updateLogCount(0);
1135
1325
  logNeedsFullRebuild = false;
1326
+ logVirtualActive = false;
1136
1327
  return;
1137
1328
  }
1138
1329
 
1139
- // Build all HTML in one pass using cached ansiToHtml results
1140
- const chunks: string[] = [];
1141
- let count = 0;
1142
- for (let i = 0; i < logLinesRaw.length; i++) {
1143
- if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1144
- count++;
1145
- const num = i + 1;
1146
- chunks.push(`<div class="log-line" data-ln="${num}"><span class="log-line-num">${num}</span><span class="log-line-content">${logLinesHtml[i]}</span></div>`);
1147
- }
1148
- logsEl.innerHTML = chunks.join('');
1330
+ // Rebuild filtered indices
1331
+ rebuildFilteredIndices();
1332
+ const count = logFilteredIndices.length;
1149
1333
  updateLogCount(count);
1334
+
1335
+ // Decide: virtual or direct
1336
+ if (count >= VIRTUAL_THRESHOLD) {
1337
+ logVirtualActive = true;
1338
+ virtualRenderSlice(logsEl);
1339
+ } else {
1340
+ logVirtualActive = false;
1341
+ // Direct render — small enough for full DOM
1342
+ const chunks: string[] = [];
1343
+ for (const idx of logFilteredIndices) {
1344
+ chunks.push(renderLogLineHtml(idx));
1345
+ }
1346
+ logsEl.innerHTML = chunks.join('');
1347
+ }
1150
1348
  logNeedsFullRebuild = false;
1349
+
1350
+ // Auto-calibrate line height from first rendered line
1351
+ if (!logLineHeightCalibrated) {
1352
+ requestAnimationFrame(() => calibrateLogLineHeight(logsEl));
1353
+ }
1151
1354
  }
1152
1355
 
1153
1356
  function appendNewLogLines(logsEl: HTMLElement, startIndex: number) {
1154
- // Fast path: append only new lines to existing DOM
1155
1357
  const search = logSearch.toLowerCase();
1156
- const fragment = document.createDocumentFragment();
1157
- let count = 0;
1358
+
1359
+ // Append to filtered indices
1158
1360
  for (let i = startIndex; i < logLinesRaw.length; i++) {
1159
1361
  if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1160
- count++;
1161
- const div = document.createElement('div');
1162
- div.className = 'log-line';
1163
- div.setAttribute('data-ln', String(i + 1));
1164
- div.innerHTML = `<span class="log-line-num">${i + 1}</span><span class="log-line-content">${logLinesHtml[i]}</span>`;
1165
- fragment.appendChild(div);
1166
- }
1167
- if (count > 0) logsEl.appendChild(fragment);
1168
- // Update total count
1169
- const total = search
1170
- ? logLinesRaw.filter(l => l.toLowerCase().includes(search)).length
1171
- : logLinesRaw.length;
1172
- updateLogCount(total);
1362
+ logFilteredIndices.push(i);
1363
+ }
1364
+ const count = logFilteredIndices.length;
1365
+ updateLogCount(count);
1366
+
1367
+ // Check if we need to switch to virtual mode
1368
+ if (count >= VIRTUAL_THRESHOLD && !logVirtualActive) {
1369
+ logVirtualActive = true;
1370
+ virtualRenderSlice(logsEl);
1371
+ return;
1372
+ }
1373
+
1374
+ if (logVirtualActive) {
1375
+ // In virtual mode, re-render the current visible slice
1376
+ virtualRenderSlice(logsEl);
1377
+ } else {
1378
+ // Direct DOM append for small logs
1379
+ const fragment = document.createDocumentFragment();
1380
+ for (let i = startIndex; i < logLinesRaw.length; i++) {
1381
+ if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1382
+ const div = document.createElement('div');
1383
+ div.className = 'log-line';
1384
+ div.setAttribute('data-ln', String(i + 1));
1385
+ div.innerHTML = `<span class="log-line-num">${i + 1}</span><span class="log-line-content">${logLinesHtml[i]}</span>`;
1386
+ fragment.appendChild(div);
1387
+ }
1388
+ if (fragment.childNodes.length > 0) logsEl.appendChild(fragment);
1389
+ }
1173
1390
  }
1174
1391
 
1175
1392
  function updateLogCount(count: number) {
1176
1393
  const countEl = $('log-line-count');
1177
- if (countEl) countEl.textContent = `${count} line${count !== 1 ? 's' : ''}`;
1394
+ if (countEl) {
1395
+ const suffix = logVirtualActive ? ' (virtual)' : '';
1396
+ countEl.textContent = `${count} line${count !== 1 ? 's' : ''}${suffix}`;
1397
+ }
1178
1398
  }
1179
1399
 
1180
1400
  async function refreshDrawerLogs() {
@@ -1287,6 +1507,10 @@ export default function mount(): () => void {
1287
1507
 
1288
1508
  // Click log line → expand/collapse (word-wrap toggle)
1289
1509
  const logsContainer = $('drawer-logs');
1510
+
1511
+ // Virtual scroll handler — drives re-render on scroll in virtual mode
1512
+ logsContainer?.addEventListener('scroll', onLogScroll, { passive: true });
1513
+
1290
1514
  logsContainer?.addEventListener('click', (e: Event) => {
1291
1515
  const line = (e.target as Element).closest('.log-line') as HTMLElement;
1292
1516
  if (!line) return;
@@ -1464,12 +1688,272 @@ export default function mount(): () => void {
1464
1688
  }
1465
1689
  });
1466
1690
 
1691
+ // ─── Templates Modal ───
1692
+
1693
+ interface TemplateData {
1694
+ name: string;
1695
+ command: string;
1696
+ workdir: string;
1697
+ env: string;
1698
+ group: string;
1699
+ created_at: string;
1700
+ }
1701
+
1702
+ let templates: TemplateData[] = [];
1703
+
1704
+ async function loadTemplates() {
1705
+ try {
1706
+ const res = await fetch('/api/templates');
1707
+ if (res.ok) {
1708
+ templates = await res.json();
1709
+ renderTemplates();
1710
+ }
1711
+ } catch (err) {
1712
+ console.error('[bgr-dashboard] loadTemplates error:', err);
1713
+ }
1714
+ }
1715
+
1716
+ function renderTemplates() {
1717
+ const list = $('templates-list');
1718
+ if (!list) return;
1719
+
1720
+ if (templates.length === 0) {
1721
+ list.innerHTML = '<div class="templates-empty">No templates saved yet</div>';
1722
+ return;
1723
+ }
1724
+
1725
+ list.replaceChildren(...templates.map(t => (
1726
+ <div className="template-item">
1727
+ <div className="template-item-info">
1728
+ <div className="template-item-name">{t.name}</div>
1729
+ <div className="template-item-command">{t.command}</div>
1730
+ </div>
1731
+ {t.group && <span className="template-item-group">{t.group}</span>}
1732
+ <div className="template-item-actions">
1733
+ <button className="use-btn" data-use={t.name} title="Use this template">Use</button>
1734
+ <button className="delete-btn" data-delete={t.name} title="Delete template">✕</button>
1735
+ </div>
1736
+ </div>
1737
+ ) as unknown as Node));
1738
+
1739
+ // Add click handlers
1740
+ list.querySelectorAll('.use-btn').forEach(btn => {
1741
+ btn.addEventListener('click', (e) => {
1742
+ const name = (e.target as HTMLElement).dataset.use;
1743
+ const tmpl = templates.find(t => t.name === name);
1744
+ if (tmpl) {
1745
+ useTemplate(tmpl);
1746
+ }
1747
+ });
1748
+ });
1749
+
1750
+ list.querySelectorAll('.delete-btn').forEach(btn => {
1751
+ btn.addEventListener('click', (e) => {
1752
+ const name = (e.target as HTMLElement).dataset.delete;
1753
+ if (name) deleteTemplate(name);
1754
+ });
1755
+ });
1756
+ }
1757
+
1758
+ function openTemplatesModal() {
1759
+ const modal = $('templates-modal');
1760
+ if (modal) modal.classList.add('active');
1761
+ loadTemplates();
1762
+ }
1763
+
1764
+ function closeTemplatesModal() {
1765
+ const modal = $('templates-modal');
1766
+ if (modal) modal.classList.remove('active');
1767
+ // Clear form
1768
+ ($('template-name') as HTMLInputElement).value = '';
1769
+ ($('template-command') as HTMLInputElement).value = '';
1770
+ ($('template-directory') as HTMLInputElement).value = '';
1771
+ ($('template-group') as HTMLInputElement).value = '';
1772
+ ($('template-env') as HTMLInputElement).value = '';
1773
+ }
1774
+
1775
+ async function saveTemplate() {
1776
+ const name = ($('template-name') as HTMLInputElement)?.value?.trim();
1777
+ const command = ($('template-command') as HTMLInputElement)?.value?.trim();
1778
+ const workdir = ($('template-directory') as HTMLInputElement)?.value?.trim();
1779
+ const group = ($('template-group') as HTMLInputElement)?.value?.trim();
1780
+ const env = ($('template-env') as HTMLInputElement)?.value?.trim();
1781
+
1782
+ if (!name || !command) {
1783
+ showToast('Name and command are required', 'error');
1784
+ return;
1785
+ }
1786
+
1787
+ try {
1788
+ const res = await fetch('/api/templates', {
1789
+ method: 'POST',
1790
+ headers: { 'Content-Type': 'application/json' },
1791
+ body: JSON.stringify({ name, command, workdir, group, env }),
1792
+ });
1793
+
1794
+ if (res.ok) {
1795
+ showToast(`Template "${name}" saved`, 'success');
1796
+ loadTemplates();
1797
+ // Clear form
1798
+ ($('template-name') as HTMLInputElement).value = '';
1799
+ ($('template-command') as HTMLInputElement).value = '';
1800
+ ($('template-directory') as HTMLInputElement).value = '';
1801
+ ($('template-group') as HTMLInputElement).value = '';
1802
+ ($('template-env') as HTMLInputElement).value = '';
1803
+ } else {
1804
+ showToast('Failed to save template', 'error');
1805
+ }
1806
+ } catch (err) {
1807
+ showToast('Failed to save template', 'error');
1808
+ }
1809
+ }
1810
+
1811
+ async function deleteTemplate(name: string) {
1812
+ if (!confirm(`Delete template "${name}"?`)) return;
1813
+
1814
+ try {
1815
+ const res = await fetch(`/api/templates?name=${encodeURIComponent(name)}`, { method: 'DELETE' });
1816
+ if (res.ok) {
1817
+ showToast(`Template "${name}" deleted`, 'success');
1818
+ loadTemplates();
1819
+ } else {
1820
+ showToast('Failed to delete template', 'error');
1821
+ }
1822
+ } catch (err) {
1823
+ showToast('Failed to delete template', 'error');
1824
+ }
1825
+ }
1826
+
1827
+ function useTemplate(tmpl: TemplateData) {
1828
+ // Fill new process form with template values
1829
+ ($('process-name-input') as HTMLInputElement).value = '';
1830
+ ($('process-command-input') as HTMLInputElement).value = tmpl.command;
1831
+ ($('process-directory-input') as HTMLInputElement).value = tmpl.workdir;
1832
+ closeTemplatesModal();
1833
+ openModal();
1834
+ showToast(`Template "${tmpl.name}" loaded — enter a process name`, 'success');
1835
+ }
1836
+
1837
+ $('templates-btn')?.addEventListener('click', openTemplatesModal);
1838
+ $('templates-modal-close')?.addEventListener('click', closeTemplatesModal);
1839
+ $('template-save-btn')?.addEventListener('click', saveTemplate);
1840
+ $('templates-modal')?.addEventListener('click', (e) => {
1841
+ if ((e.target as Element).classList.contains('modal-overlay')) {
1842
+ closeTemplatesModal();
1843
+ }
1844
+ });
1845
+
1846
+ // ─── History Modal ───
1847
+
1848
+ interface HistoryEntry {
1849
+ process_name: string;
1850
+ event: string;
1851
+ pid: number | null;
1852
+ timestamp: string;
1853
+ metadata: Record<string, any>;
1854
+ }
1855
+
1856
+ let allHistory: HistoryEntry[] = [];
1857
+
1858
+ async function loadHistory() {
1859
+ try {
1860
+ const res = await fetch('/api/history?limit=100');
1861
+ if (res.ok) {
1862
+ allHistory = await res.json();
1863
+ renderHistory();
1864
+ updateHistoryFilters();
1865
+ }
1866
+ } catch (err) {
1867
+ console.error('[bgr-dashboard] loadHistory error:', err);
1868
+ }
1869
+ }
1870
+
1871
+ function updateHistoryFilters() {
1872
+ const processFilter = $('history-process-filter') as HTMLSelectElement;
1873
+ const eventFilter = $('history-event-filter') as HTMLSelectElement;
1874
+ if (!processFilter) return;
1875
+
1876
+ const processNames = new Set<string>();
1877
+ for (const h of allHistory) {
1878
+ processNames.add(h.process_name);
1879
+ }
1880
+
1881
+ const currentValue = processFilter.value;
1882
+ processFilter.replaceChildren(
1883
+ <option value="">All Processes</option> as unknown as Node,
1884
+ ...Array.from(processNames).sort().map(n => <option value={n}>{n}</option> as unknown as Node)
1885
+ );
1886
+ if (currentValue && processNames.has(currentValue)) {
1887
+ processFilter.value = currentValue;
1888
+ }
1889
+ }
1890
+
1891
+ function renderHistory() {
1892
+ const list = $('history-list');
1893
+ const processFilter = $('history-process-filter') as HTMLSelectElement;
1894
+ const eventFilter = $('history-event-filter') as HTMLSelectElement;
1895
+ if (!list) return;
1896
+
1897
+ const processValue = processFilter?.value || '';
1898
+ const eventValue = eventFilter?.value || '';
1899
+
1900
+ let filtered = allHistory;
1901
+ if (processValue) {
1902
+ filtered = filtered.filter(h => h.process_name === processValue);
1903
+ }
1904
+ if (eventValue) {
1905
+ filtered = filtered.filter(h => h.event === eventValue);
1906
+ }
1907
+
1908
+ if (filtered.length === 0) {
1909
+ list.innerHTML = '<div class="history-empty">No history found</div>';
1910
+ return;
1911
+ }
1912
+
1913
+ list.replaceChildren(...filtered.map(h => {
1914
+ const time = new Date(h.timestamp);
1915
+ const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' ' + time.toLocaleDateString([], { month: 'short', day: 'numeric' });
1916
+ return (
1917
+ <div className="history-item">
1918
+ <span className="history-item-time">{timeStr}</span>
1919
+ <span className="history-item-process">{h.process_name}</span>
1920
+ <span className={`history-item-event ${h.event}`}>{h.event.replace('_', ' ')}</span>
1921
+ {h.pid && <span className="history-item-pid">PID {h.pid}</span>}
1922
+ </div>
1923
+ ) as unknown as Node;
1924
+ }));
1925
+ }
1926
+
1927
+ function openHistoryModal() {
1928
+ const modal = $('history-modal');
1929
+ if (modal) modal.classList.add('active');
1930
+ loadHistory();
1931
+ }
1932
+
1933
+ function closeHistoryModal() {
1934
+ const modal = $('history-modal');
1935
+ if (modal) modal.classList.remove('active');
1936
+ }
1937
+
1938
+ $('history-btn')?.addEventListener('click', openHistoryModal);
1939
+ $('history-modal-close')?.addEventListener('click', closeHistoryModal);
1940
+ $('history-modal')?.addEventListener('click', (e) => {
1941
+ if ((e.target as Element).classList.contains('modal-overlay')) {
1942
+ closeHistoryModal();
1943
+ }
1944
+ });
1945
+ $('history-process-filter')?.addEventListener('change', renderHistory);
1946
+ $('history-event-filter')?.addEventListener('change', renderHistory);
1947
+
1467
1948
  // ─── Toolbar Actions ───
1468
1949
  $('refresh-btn')?.addEventListener('click', () => {
1469
1950
  loadProcesses();
1470
1951
  if (drawerProcess) refreshDrawerLogs();
1471
1952
  });
1472
1953
 
1954
+ // ─── Shortcuts Button ───
1955
+ $('shortcuts-btn')?.addEventListener('click', toggleShortcutsOverlay);
1956
+
1473
1957
  // ─── Guard All Button ───
1474
1958
  $('guard-all-btn')?.addEventListener('click', async () => {
1475
1959
  const guardAllBtn = $('guard-all-btn') as HTMLButtonElement;
@@ -1513,14 +1997,95 @@ export default function mount(): () => void {
1513
1997
  // Group toggle removed — always-on directory grouping
1514
1998
 
1515
1999
  // ─── Keyboard Shortcuts ───
2000
+ let focusedProcessName: string | null = null;
2001
+
2002
+ function getFocusableRows(): HTMLElement[] {
2003
+ const rows = tbody?.querySelectorAll('tr[data-process-name]') as NodeListOf<HTMLElement> | undefined;
2004
+ return rows ? Array.from(rows) : [];
2005
+ }
2006
+
2007
+ function setProcessFocus(name: string | null) {
2008
+ // Remove previous focus
2009
+ tbody?.querySelectorAll('tr.keyboard-focus').forEach(r => r.classList.remove('keyboard-focus'));
2010
+ focusedProcessName = name;
2011
+ if (!name) return;
2012
+ const row = tbody?.querySelector(`tr[data-process-name="${name}"]`) as HTMLElement;
2013
+ if (row) {
2014
+ row.classList.add('keyboard-focus');
2015
+ row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2016
+ }
2017
+ }
2018
+
2019
+ function navigateProcess(direction: 'up' | 'down') {
2020
+ const rows = getFocusableRows();
2021
+ if (rows.length === 0) return;
2022
+
2023
+ if (!focusedProcessName) {
2024
+ // Nothing focused: pick first or last
2025
+ const target = direction === 'down' ? rows[0] : rows[rows.length - 1];
2026
+ setProcessFocus(target.dataset.processName || null);
2027
+ return;
2028
+ }
2029
+
2030
+ const idx = rows.findIndex(r => r.dataset.processName === focusedProcessName);
2031
+ if (idx === -1) {
2032
+ setProcessFocus(rows[0].dataset.processName || null);
2033
+ return;
2034
+ }
2035
+
2036
+ const nextIdx = direction === 'down'
2037
+ ? Math.min(idx + 1, rows.length - 1)
2038
+ : Math.max(idx - 1, 0);
2039
+ setProcessFocus(rows[nextIdx].dataset.processName || null);
2040
+ }
2041
+
2042
+ /** Dispatch a process action by synthesizing a click on a virtual button */
2043
+ function dispatchAction(actionName: string, processName: string) {
2044
+ const fakeBtn = document.createElement('button');
2045
+ fakeBtn.dataset.action = actionName;
2046
+ fakeBtn.dataset.name = processName;
2047
+ // For guard toggle, read current state
2048
+ if (actionName === 'guard') {
2049
+ const proc = allProcesses.find(p => p.name === processName);
2050
+ fakeBtn.dataset.guarded = proc && isGuarded(proc) ? 'true' : 'false';
2051
+ }
2052
+ const fakeEvent = new MouseEvent('click');
2053
+ Object.defineProperty(fakeEvent, 'target', { value: fakeBtn });
2054
+ handleAction(fakeEvent);
2055
+ }
2056
+
2057
+ function toggleShortcutsOverlay() {
2058
+ const overlay = $('shortcuts-overlay');
2059
+ if (overlay) overlay.classList.toggle('active');
2060
+ }
2061
+
2062
+ $('shortcuts-close-btn')?.addEventListener('click', () => {
2063
+ $('shortcuts-overlay')?.classList.remove('active');
2064
+ });
2065
+ $('shortcuts-overlay')?.addEventListener('click', (e) => {
2066
+ if ((e.target as Element).classList.contains('shortcuts-overlay')) {
2067
+ $('shortcuts-overlay')?.classList.remove('active');
2068
+ }
2069
+ });
2070
+
1516
2071
  function handleKeydown(e: KeyboardEvent) {
2072
+ // Skip all shortcuts when inside text inputs or textareas
2073
+ const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
2074
+
1517
2075
  // "/" to focus search (unless already in an input)
1518
- if (e.key === '/' && !(e.target instanceof HTMLInputElement)) {
2076
+ if (e.key === '/' && !inInput) {
1519
2077
  e.preventDefault();
1520
2078
  searchInput?.focus();
1521
2079
  return;
1522
2080
  }
2081
+
2082
+ // Escape: close overlays progressively
1523
2083
  if (e.key === 'Escape') {
2084
+ const shortcutsOverlay = $('shortcuts-overlay');
2085
+ if (shortcutsOverlay?.classList.contains('active')) {
2086
+ shortcutsOverlay.classList.remove('active');
2087
+ return;
2088
+ }
1524
2089
  if (contextMenuEl) {
1525
2090
  closeContextMenu();
1526
2091
  return;
@@ -1530,10 +2095,75 @@ export default function mount(): () => void {
1530
2095
  } else {
1531
2096
  closeModal();
1532
2097
  }
2098
+ // Clear keyboard focus
2099
+ setProcessFocus(null);
1533
2100
  // Blur search on escape
1534
2101
  if (document.activeElement === searchInput) {
1535
2102
  searchInput?.blur();
1536
2103
  }
2104
+ return;
2105
+ }
2106
+
2107
+ // Remaining shortcuts only when NOT in inputs
2108
+ if (inInput) return;
2109
+
2110
+ // Arrow navigation
2111
+ if (e.key === 'ArrowDown' || e.key === 'j') {
2112
+ e.preventDefault();
2113
+ navigateProcess('down');
2114
+ return;
2115
+ }
2116
+ if (e.key === 'ArrowUp' || e.key === 'k') {
2117
+ e.preventDefault();
2118
+ navigateProcess('up');
2119
+ return;
2120
+ }
2121
+
2122
+ // Enter: open drawer for focused process
2123
+ if (e.key === 'Enter' && focusedProcessName) {
2124
+ e.preventDefault();
2125
+ openDrawer(focusedProcessName);
2126
+ return;
2127
+ }
2128
+
2129
+ // ? — help overlay
2130
+ if (e.key === '?') {
2131
+ e.preventDefault();
2132
+ toggleShortcutsOverlay();
2133
+ return;
2134
+ }
2135
+
2136
+ // N — new process modal
2137
+ if (e.key === 'n' || e.key === 'N') {
2138
+ e.preventDefault();
2139
+ openModal();
2140
+ return;
2141
+ }
2142
+
2143
+ // Process actions — require a focused row
2144
+ if (!focusedProcessName) return;
2145
+
2146
+ if (e.key === 'r' || e.key === 'R') {
2147
+ e.preventDefault();
2148
+ dispatchAction('restart', focusedProcessName);
2149
+ return;
2150
+ }
2151
+ if (e.key === 's' || e.key === 'S') {
2152
+ e.preventDefault();
2153
+ dispatchAction('stop', focusedProcessName);
2154
+ return;
2155
+ }
2156
+ if (e.key === 'g' || e.key === 'G') {
2157
+ e.preventDefault();
2158
+ dispatchAction('guard', focusedProcessName);
2159
+ return;
2160
+ }
2161
+ if (e.key === 'd' || e.key === 'D') {
2162
+ e.preventDefault();
2163
+ dispatchAction('delete', focusedProcessName);
2164
+ // Clear focus since process is gone
2165
+ setProcessFocus(null);
2166
+ return;
1537
2167
  }
1538
2168
  }
1539
2169
  document.addEventListener('keydown', handleKeydown);
@@ -1542,10 +2172,17 @@ export default function mount(): () => void {
1542
2172
  let eventSource: EventSource | null = null;
1543
2173
  let logRefreshTimer: ReturnType<typeof setInterval> | null = null;
1544
2174
  let sseThrottleTimer: ReturnType<typeof setTimeout> | null = null;
2175
+ let sseRetryDelay = 2_000; // exponential backoff start
2176
+ const SSE_MAX_RETRY = 30_000; // max 30s between retries
2177
+
2178
+ // Initial data load — don't depend on SSE for first render
2179
+ loadProcesses();
1545
2180
 
1546
2181
  function connectSSE() {
2182
+ if (eventSource) { eventSource.close(); eventSource = null; }
1547
2183
  eventSource = new EventSource('/api/events');
1548
2184
  eventSource.onmessage = (event) => {
2185
+ sseRetryDelay = 2_000; // reset backoff on success
1549
2186
  // Skip SSE updates briefly after mutations to avoid flicker
1550
2187
  if (Date.now() < mutationUntil) return;
1551
2188
  try {
@@ -1561,14 +2198,29 @@ export default function mount(): () => void {
1561
2198
  } catch { /* invalid data, skip */ }
1562
2199
  };
1563
2200
  eventSource.onerror = () => {
1564
- // SSE disconnected, reconnect after 5s
2201
+ // SSE disconnected exponential backoff reconnect
1565
2202
  eventSource?.close();
1566
2203
  eventSource = null;
1567
- setTimeout(connectSSE, 5000);
2204
+ setTimeout(connectSSE, sseRetryDelay);
2205
+ sseRetryDelay = Math.min(sseRetryDelay * 2, SSE_MAX_RETRY);
1568
2206
  };
1569
2207
  }
1570
2208
  connectSSE();
1571
2209
 
2210
+ // Pause SSE when tab is hidden, resume when visible
2211
+ function handleVisibility() {
2212
+ if (document.hidden) {
2213
+ eventSource?.close();
2214
+ eventSource = null;
2215
+ } else {
2216
+ if (!eventSource) {
2217
+ sseRetryDelay = 2_000; // reset on manual re-focus
2218
+ connectSSE();
2219
+ }
2220
+ }
2221
+ }
2222
+ document.addEventListener('visibilitychange', handleVisibility);
2223
+
1572
2224
  // Log drawer still needs periodic refresh (not part of SSE)
1573
2225
  logRefreshTimer = setInterval(() => {
1574
2226
  if (drawerProcess) refreshDrawerLogs();
@@ -1584,9 +2236,13 @@ export default function mount(): () => void {
1584
2236
  $('modal-create-btn')?.removeEventListener('click', createProcess);
1585
2237
  $('refresh-btn')?.removeEventListener('click', loadProcesses);
1586
2238
  document.removeEventListener('keydown', handleKeydown);
2239
+ document.removeEventListener('visibilitychange', handleVisibility);
1587
2240
  closeContextMenu();
1588
2241
  if (eventSource) eventSource.close();
1589
2242
  if (logRefreshTimer) clearInterval(logRefreshTimer);
1590
2243
  if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
2244
+ if (searchDebounce) clearTimeout(searchDebounce);
2245
+ if (logScrollRAF) cancelAnimationFrame(logScrollRAF);
2246
+ logsContainer?.removeEventListener('scroll', onLogScroll);
1591
2247
  };
1592
2248
  }