bgrun 3.10.2 → 3.12.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.
@@ -1,15 +1,10 @@
1
- /** @jsxImportSource react */
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;
@@ -26,6 +21,10 @@ interface ProcessData {
26
21
  configPath: string;
27
22
  stdoutPath: string;
28
23
  stderrPath: string;
24
+ guardRestarts: number;
25
+ cpu?: number;
26
+ cpuHistory?: number[];
27
+ memoryHistory?: number[];
29
28
  }
30
29
 
31
30
  // ─── SVG Icon Helpers ───
@@ -94,6 +93,51 @@ function DeployIcon() {
94
93
  );
95
94
  }
96
95
 
96
+ function ShieldIcon() {
97
+ return (
98
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
99
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
100
+ </svg>
101
+ );
102
+ }
103
+
104
+ // ─── Sparkline Component ───
105
+ function MiniSparkline({ data, height = 24, stroke = "var(--text-accent)" }: { data: number[], height?: number, stroke?: string }) {
106
+ if (!data || data.length < 2) return <svg className="sparkline" viewBox="0 0 100 24" height={height} width="60" />;
107
+
108
+ const max = Math.max(...data, 1);
109
+ const min = Math.min(...data, 0); // anchor to 0 minimum
110
+ const range = max - min || 1;
111
+ const padding = 2; // top/bottom pixel padding
112
+
113
+ // Normalize path across 100 viewBox width
114
+ const path = data.map((val, i) => {
115
+ const x = (i / (data.length - 1)) * 100;
116
+ const normalizedY = ((val - min) / range);
117
+ const y = padding + (1 - normalizedY) * (24 - padding * 2);
118
+ return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
119
+ }).join(' ');
120
+
121
+ return (
122
+ <svg className="sparkline" viewBox="0 0 100 24" height={height} width="60" fill="none" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle', marginLeft: '6px', opacity: 0.8 }}>
123
+ <path d={path} />
124
+ </svg>
125
+ );
126
+ }
127
+
128
+ // ─── Guard Helper ───
129
+
130
+ function isGuarded(p: ProcessData): boolean {
131
+ if (!p.env) return false;
132
+ // env is comma-separated "KEY=VAL,KEY2=VAL2" or JSON string
133
+ try {
134
+ const parsed = JSON.parse(p.env);
135
+ return parsed.BGR_KEEP_ALIVE === 'true';
136
+ } catch {
137
+ return p.env.includes('BGR_KEEP_ALIVE=true');
138
+ }
139
+ }
140
+
97
141
  // ─── Utility: Format Runtime ───
98
142
 
99
143
  function formatRuntime(raw: string): string {
@@ -148,11 +192,22 @@ function shortenPath(dir: string): string {
148
192
  // ─── JSX Components ───
149
193
 
150
194
  function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
195
+ const guarded = isGuarded(p);
151
196
  return (
152
197
  <tr data-process-name={p.name} className={animate ? 'animate-in' : ''} style={animate ? { opacity: '0' } : undefined}>
153
198
  <td>
154
199
  <div className="process-name">
155
200
  <span>{p.name}</span>
201
+ <button
202
+ className={`guard-toggle ${guarded ? 'guarded' : ''}`}
203
+ data-action="guard"
204
+ data-name={p.name}
205
+ data-guarded={guarded ? 'true' : 'false'}
206
+ title={guarded ? 'Process is guarded — click to disable auto-restart' : 'Click to enable auto-restart guard'}
207
+ onClick={(e: Event) => e.stopPropagation()}
208
+ >
209
+ <ShieldIcon />
210
+ </button>
156
211
  </div>
157
212
  </td>
158
213
  <td>
@@ -168,14 +223,26 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
168
223
  : <span style={{ color: 'var(--text-muted)' }}>–</span>
169
224
  }
170
225
  </td>
226
+ <td className="cpu">
227
+ {p.running && (p.cpu !== undefined)
228
+ ? <div className="metrics-cell">
229
+ <span>{p.cpu > 0 ? `${p.cpu.toFixed(1)}%` : '<0.1%'}</span>
230
+ <MiniSparkline data={p.cpuHistory || []} stroke="#7ee787" />
231
+ </div>
232
+ : <span style={{ color: 'var(--text-muted)' }}>–</span>
233
+ }
234
+ </td>
171
235
  <td className="memory">
172
- {p.memory > 0
173
- ? <span className="memory-badge">{formatMemory(p.memory)}</span>
236
+ {p.running && p.memory > 0
237
+ ? <div className="metrics-cell">
238
+ <span className="memory-badge">{formatMemory(p.memory)}</span>
239
+ <MiniSparkline data={p.memoryHistory || []} stroke="#a5d6ff" />
240
+ </div>
174
241
  : <span style={{ color: 'var(--text-muted)' }}>–</span>
175
242
  }
176
243
  </td>
177
244
  <td className="command" title={p.command}>{p.command}</td>
178
- <td className="runtime">{formatRuntime(p.runtime)}</td>
245
+ <td className="runtime">{formatRuntime(String(p.runtime))}</td>
179
246
  </tr>
180
247
  );
181
248
  }
@@ -215,11 +282,13 @@ function EmptyState() {
215
282
  }
216
283
 
217
284
  function ProcessCard({ p }: { p: ProcessData }) {
285
+ const guarded = isGuarded(p);
218
286
  return (
219
287
  <div className="process-card" data-process-name={p.name}>
220
288
  <div className="card-header">
221
289
  <div className="process-name">
222
290
  <span>{p.name}</span>
291
+ {guarded && <span className="guard-badge" title="Auto-restart enabled">🛡️</span>}
223
292
  </div>
224
293
  <span className={`status-badge ${p.running ? 'running' : 'stopped'}`}>
225
294
  <span className="status-dot"></span>
@@ -229,11 +298,15 @@ function ProcessCard({ p }: { p: ProcessData }) {
229
298
  <div className="card-details">
230
299
  <div className="card-detail"><span className="card-label">PID</span><span>{p.pid}</span></div>
231
300
  <div className="card-detail"><span className="card-label">Port</span>{p.port ? <a className="port-link" href={`http://localhost:${p.port}`} target="_blank" rel="noopener" onClick={(e: Event) => e.stopPropagation()}>:{p.port}</a> : <span>–</span>}</div>
232
- <div className="card-detail"><span className="card-label">Memory</span><span>{p.memory > 0 ? formatMemory(p.memory) : ''}</span></div>
233
- <div className="card-detail"><span className="card-label">Runtime</span><span>{formatRuntime(p.runtime)}</span></div>
301
+ <div className="card-detail"><span className="card-label">CPU</span>{p.running && (p.cpu !== undefined) ? <div style={{ display: 'flex', alignItems: 'center' }}><span>{p.cpu > 0 ? `${p.cpu.toFixed(1)}%` : '<0.1%'}</span><MiniSparkline data={p.cpuHistory || []} stroke="#7ee787" /></div> : <span>–</span>}</div>
302
+ <div className="card-detail"><span className="card-label">Memory</span>{p.running && p.memory > 0 ? <div style={{ display: 'flex', alignItems: 'center' }}><span>{formatMemory(p.memory)}</span><MiniSparkline data={p.memoryHistory || []} stroke="#a5d6ff" /></div> : <span>–</span>}</div>
303
+ <div className="card-detail"><span className="card-label">Runtime</span><span>{formatRuntime(String(p.runtime))}</span></div>
234
304
  </div>
235
305
  <div className="card-command" title={p.command}>{p.command}</div>
236
306
  <div className="card-actions">
307
+ <button className={`action-btn guard ${guarded ? 'active' : ''}`} data-action="guard" data-name={p.name} data-guarded={guarded ? 'true' : 'false'} title={guarded ? 'Disable auto-restart' : 'Enable auto-restart'}>
308
+ <ShieldIcon /> {guarded ? 'Unguard' : 'Guard'}
309
+ </button>
237
310
  <button className="action-btn info" data-action="logs" data-name={p.name} title="View Logs">
238
311
  <LogsIcon /> Logs
239
312
  </button>
@@ -340,6 +413,7 @@ export default function mount(): () => void {
340
413
  let isFirstLoad = true;
341
414
  let allProcesses: ProcessData[] = [];
342
415
  let searchQuery = '';
416
+ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
343
417
  let collapsedGroups: Set<string> = new Set(JSON.parse(localStorage.getItem('bgr_collapsed_groups') || '[]'));
344
418
  let drawerProcess: string | null = null;
345
419
  let drawerTab: 'stdout' | 'stderr' = 'stdout';
@@ -355,6 +429,28 @@ export default function mount(): () => void {
355
429
  let logLastSize = -1; // Detect no-change polls
356
430
  let logNeedsFullRebuild = true; // Full DOM rebuild flag (on tab switch, search change)
357
431
 
432
+ // ─── Virtual Scrolling State ───
433
+ let LOG_LINE_HEIGHT = 22; // default estimate, auto-calibrated on first render
434
+ let logLineHeightCalibrated = false;
435
+ const LOG_OVERSCAN = 10; // extra lines rendered above/below viewport
436
+ const VIRTUAL_THRESHOLD = 200; // switch to virtual mode above this many lines
437
+ let logVirtualActive = false; // whether virtual scrolling is engaged
438
+ let logFilteredIndices: number[] = []; // indices into logLinesRaw that pass the search filter
439
+ let logScrollRAF: number | null = null; // rAF handle for throttled scroll
440
+
441
+ /** Measure actual log line height from DOM on first render */
442
+ function calibrateLogLineHeight(logsEl: HTMLElement) {
443
+ if (logLineHeightCalibrated) return;
444
+ const firstLine = logsEl.querySelector('.log-line') as HTMLElement;
445
+ if (firstLine) {
446
+ const measured = firstLine.getBoundingClientRect().height;
447
+ if (measured > 0) {
448
+ LOG_LINE_HEIGHT = Math.round(measured);
449
+ logLineHeightCalibrated = true;
450
+ }
451
+ }
452
+ }
453
+
358
454
  // ─── Version Badge ───
359
455
  const versionBadge = $('version-badge');
360
456
  async function loadVersion() {
@@ -379,14 +475,18 @@ export default function mount(): () => void {
379
475
  allProcesses = await res.json();
380
476
  renderFilteredProcesses();
381
477
  updateStats(allProcesses);
382
- } catch {
383
- // silently retry on next tick
478
+ } catch (err) {
479
+ console.error('[bgr-dashboard] loadProcesses error:', err);
384
480
  } finally {
385
481
  isFetching = false;
386
482
  }
387
483
  }
388
484
 
389
485
  function renderFilteredProcesses() {
486
+ // Always sync searchQuery from DOM to prevent desync
487
+ if (searchInput && searchInput.value.toLowerCase().trim() !== searchQuery) {
488
+ searchQuery = searchInput.value.toLowerCase().trim();
489
+ }
390
490
  const filtered = searchQuery
391
491
  ? allProcesses.filter(p =>
392
492
  p.name.toLowerCase().includes(searchQuery) ||
@@ -395,22 +495,78 @@ export default function mount(): () => void {
395
495
  )
396
496
  : allProcesses;
397
497
  renderProcesses(filtered);
498
+
499
+ // Update search result count badge
500
+ const badge = $('search-count');
501
+ if (badge) {
502
+ if (searchQuery) {
503
+ badge.textContent = `${filtered.length}/${allProcesses.length}`;
504
+ badge.style.display = 'inline-block';
505
+ } else {
506
+ badge.style.display = 'none';
507
+ }
508
+ }
398
509
  }
399
510
 
400
511
  function updateStats(processes: ProcessData[]) {
401
512
  const total = processes.length;
402
513
  const running = processes.filter(p => p.running).length;
403
514
  const stopped = total - running;
515
+ const guarded = processes.filter(p => isGuarded(p)).length;
516
+ const guardable = processes.filter(p => p.name !== 'bgr-dashboard').length;
404
517
  const totalMemory = processes.reduce((sum, p) => sum + (p.memory || 0), 0);
405
518
 
406
519
  const tc = $('total-count');
407
520
  const rc = $('running-count');
408
521
  const sc = $('stopped-count');
522
+ const gc = $('guarded-count');
409
523
  const mc = $('memory-count');
524
+ const rrc = $('restarts-count');
410
525
  if (tc) tc.textContent = String(total);
411
526
  if (rc) rc.textContent = String(running);
412
527
  if (sc) sc.textContent = String(stopped);
528
+ if (gc) gc.textContent = String(guarded);
413
529
  if (mc) mc.textContent = formatMemory(totalMemory) || '0 MB';
530
+ const totalRestarts = processes.reduce((sum, p) => sum + (p.guardRestarts || 0), 0);
531
+ if (rrc) rrc.textContent = String(totalRestarts);
532
+
533
+ // Update Guard All button state
534
+ const guardAllBtn = $('guard-all-btn');
535
+ const guardAllLabel = $('guard-all-label');
536
+ if (guardAllBtn && guardAllLabel) {
537
+ const allGuarded = guardable > 0 && guarded >= guardable;
538
+ guardAllBtn.classList.toggle('all-guarded', allGuarded);
539
+ guardAllLabel.textContent = allGuarded ? 'Unguard All' : 'Guard All';
540
+ guardAllBtn.title = allGuarded ? 'Remove guard from all processes' : 'Guard all processes (auto-restart on crash)';
541
+ }
542
+
543
+ // Update guard sentinel pill
544
+ const guardPill = $('guard-sentinel-pill');
545
+ const guardLabel = $('guard-sentinel-label');
546
+ if (guardPill && guardLabel) {
547
+ const guardProc = processes.find(p => p.name === 'bgr-guard');
548
+ guardPill.classList.remove('active', 'stopped');
549
+ if (guardProc && guardProc.running) {
550
+ guardPill.classList.add('active');
551
+ const restarts = guardProc.guardRestarts || 0;
552
+ guardLabel.textContent = restarts > 0 ? `Guard: ON (${restarts}↻)` : 'Guard: ON';
553
+ } else if (guardProc) {
554
+ guardPill.classList.add('stopped');
555
+ guardLabel.textContent = 'Guard: OFF';
556
+ } else {
557
+ guardLabel.textContent = 'Guard: –';
558
+ }
559
+ }
560
+ }
561
+
562
+ function toggleGroup(groupDir: string) {
563
+ if (collapsedGroups.has(groupDir)) {
564
+ collapsedGroups.delete(groupDir);
565
+ } else {
566
+ collapsedGroups.add(groupDir);
567
+ }
568
+ localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
569
+ renderFilteredProcesses();
414
570
  }
415
571
 
416
572
  function renderProcesses(processes: ProcessData[]) {
@@ -440,62 +596,65 @@ export default function mount(): () => void {
440
596
  groups[key].push(p);
441
597
  });
442
598
 
443
- const nodes: Node[] = [];
444
599
  const sortedGroupKeys = Object.keys(groups).sort();
445
600
 
446
- // Always show group headers for every directory
601
+ // Build DOM nodes for table rows
602
+ const rows: Node[] = [];
447
603
  sortedGroupKeys.forEach(groupDir => {
448
604
  const procs = groups[groupDir];
449
605
  const running = procs.filter(p => p.running).length;
450
606
  const collapsed = collapsedGroups.has(groupDir);
451
- nodes.push(<GroupHeader name={groupDir} running={running} total={procs.length} collapsed={collapsed} /> as unknown as Node);
607
+ rows.push(<GroupHeader name={groupDir} running={running} total={procs.length} collapsed={collapsed} /> as unknown as Node);
452
608
  if (!collapsed) {
453
609
  procs.forEach(p => {
454
- nodes.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
610
+ rows.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
455
611
  });
456
612
  }
457
613
  });
458
614
 
459
- tbody.replaceChildren(...nodes);
615
+ // Replace tbody contents with new DOM nodes
616
+ tbody.replaceChildren(...rows);
460
617
 
461
618
  // Add click handlers for group headers (toggle collapse)
462
619
  tbody.querySelectorAll('.group-header').forEach(header => {
463
620
  header.addEventListener('click', (e: Event) => {
464
- // Don't collapse if clicking action buttons
465
621
  if ((e.target as Element).closest('[data-action]')) return;
466
622
  const groupName = (header as HTMLElement).dataset.groupName;
467
623
  if (!groupName) return;
468
- if (collapsedGroups.has(groupName)) {
469
- collapsedGroups.delete(groupName);
470
- } else {
471
- collapsedGroups.add(groupName);
472
- }
473
- localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
474
- renderFilteredProcesses();
624
+ toggleGroup(groupName);
475
625
  });
476
626
  });
477
627
 
478
628
  // Render mobile cards
479
629
  if (cardsEl) {
480
- const cards = processes.map(p => <ProcessCard p={p} /> as unknown as Node);
481
- cardsEl.replaceChildren(...cards);
630
+ cardsEl.replaceChildren(...processes.map(p => <ProcessCard p={p} /> as unknown as Node));
482
631
  }
483
632
 
484
633
  if (isFirstLoad) isFirstLoad = false;
485
634
 
486
- // Highlight selected row
635
+ // Restore selected row + keyboard focus row
487
636
  if (drawerProcess) {
488
- const row = tbody.querySelector(`tr[data-process-name="${drawerProcess}"]`);
637
+ const finalTbody = $('processes-table') || tbody;
638
+ const row = finalTbody.querySelector(`tr[data-process-name="${drawerProcess}"]`);
489
639
  if (row) row.classList.add('selected');
490
640
  }
641
+ // Restore keyboard focus ring if user had a row focused
642
+ if (focusedProcessName) {
643
+ const finalTbody = $('processes-table') || tbody;
644
+ const focusRow = finalTbody.querySelector(`tr[data-process-name="${focusedProcessName}"]`);
645
+ if (focusRow) focusRow.classList.add('focus-ring');
646
+ }
491
647
  }
492
648
 
493
- // ─── Search ───
649
+ // ─── Search (debounced 150ms) ───
494
650
 
495
651
  const searchInput = $('search-input') as HTMLInputElement;
496
652
  searchInput?.addEventListener('input', () => {
497
- searchQuery = searchInput.value.toLowerCase().trim();
498
- renderFilteredProcesses();
653
+ if (searchDebounce) clearTimeout(searchDebounce);
654
+ searchDebounce = setTimeout(() => {
655
+ searchQuery = searchInput.value.toLowerCase().trim();
656
+ renderFilteredProcesses();
657
+ }, 150);
499
658
  });
500
659
 
501
660
  /** Fetch with cache-bust to force fresh data after mutations */
@@ -608,6 +767,29 @@ export default function mount(): () => void {
608
767
  break;
609
768
  }
610
769
 
770
+ case 'guard': {
771
+ const currentlyGuarded = btn.dataset.guarded === 'true';
772
+ const newState = !currentlyGuarded;
773
+ try {
774
+ const res = await fetch('/api/guard', {
775
+ method: 'POST',
776
+ headers: { 'Content-Type': 'application/json' },
777
+ body: JSON.stringify({ name, enabled: newState }),
778
+ });
779
+ if (res.ok) {
780
+ showToast(`${newState ? 'Guarded' : 'Unguarded'} "${name}"`, 'success');
781
+ } else {
782
+ const data = await res.json();
783
+ showToast(data.error || `Failed to toggle guard for "${name}"`, 'error');
784
+ }
785
+ } catch {
786
+ showToast(`Failed to toggle guard for "${name}"`, 'error');
787
+ }
788
+ await loadProcessesFresh();
789
+ mutationUntil = Date.now() + 3000;
790
+ break;
791
+ }
792
+
611
793
  case 'logs':
612
794
  openDrawer(name);
613
795
  break;
@@ -631,12 +813,17 @@ export default function mount(): () => void {
631
813
  const proc = allProcesses.find(p => p.name === name);
632
814
  if (!proc) return;
633
815
 
816
+ const guarded = isGuarded(proc);
634
817
  const menu = (
635
818
  <div className="context-menu" style={{ left: `${x}px`, top: `${y}px` }}>
636
819
  <button className="context-item" data-action="logs" data-name={name}>
637
820
  <LogsIcon /> View Logs
638
821
  </button>
639
822
  <div className="context-divider"></div>
823
+ <button className={`context-item ${guarded ? 'guard-active' : 'guard'}`} data-action="guard" data-name={name} data-guarded={guarded ? 'true' : 'false'}>
824
+ <ShieldIcon /> {guarded ? 'Disable Guard' : 'Enable Guard'}
825
+ </button>
826
+ <div className="context-divider"></div>
640
827
  {proc.running
641
828
  ? <button className="context-item danger" data-action="stop" data-name={name}>
642
829
  <StopIcon /> Stop
@@ -784,6 +971,8 @@ export default function mount(): () => void {
784
971
  logCurrentTab = '';
785
972
  logLastSize = -1;
786
973
  logNeedsFullRebuild = true;
974
+ logVirtualActive = false;
975
+ logFilteredIndices = [];
787
976
  if (!skipRefresh) refreshDrawerLogs();
788
977
  }
789
978
 
@@ -842,11 +1031,12 @@ export default function mount(): () => void {
842
1031
  const proc = allProcesses.find(p => p.name === name);
843
1032
  const meta = $('drawer-meta');
844
1033
  if (meta && proc) {
1034
+ const guarded = isGuarded(proc);
845
1035
  const metaItems = [
846
1036
  { label: 'Status', value: proc.running ? '● Running' : '○ Stopped' },
847
1037
  { label: 'PID', value: String(proc.pid) },
848
1038
  { label: 'Port', value: proc.port ? `:${proc.port}` : '–', href: proc.port ? `http://localhost:${proc.port}` : undefined },
849
- { label: 'Runtime', value: formatRuntime(proc.runtime) },
1039
+ { label: 'Runtime', value: formatRuntime(String(proc.runtime)) },
850
1040
  { label: 'Command', value: proc.command },
851
1041
  { label: 'Directory', value: proc.directory || '–' },
852
1042
  { label: 'Memory', value: formatMemory(proc.memory) },
@@ -862,7 +1052,74 @@ export default function mount(): () => void {
862
1052
  }
863
1053
  </div>
864
1054
  ) as unknown as Node);
865
- meta.replaceChildren(...items);
1055
+
1056
+ // Guard toggle row with inline switch
1057
+ const guardRow = (
1058
+ <div className={`meta-item meta-guard ${guarded ? 'guarded' : ''}`}>
1059
+ <span className="meta-label">
1060
+ <ShieldIcon /> Guard
1061
+ </span>
1062
+ <label className="guard-toggle" title={guarded ? 'Auto-restart is ON — click to disable' : 'Auto-restart is OFF — click to enable'}>
1063
+ <input type="checkbox" checked={guarded} className="guard-toggle-input" />
1064
+ <span className="guard-toggle-track">
1065
+ <span className="guard-toggle-thumb"></span>
1066
+ </span>
1067
+ <span className="guard-toggle-label">{guarded ? 'Protected' : 'Off'}</span>
1068
+ </label>
1069
+ </div>
1070
+ ) as unknown as HTMLElement;
1071
+
1072
+ // Wire toggle click
1073
+ const checkbox = guardRow.querySelector('.guard-toggle-input') as HTMLInputElement;
1074
+ checkbox?.addEventListener('change', async () => {
1075
+ const newState = checkbox.checked;
1076
+ const labelEl = guardRow.querySelector('.guard-toggle-label');
1077
+ const trackEl = guardRow.querySelector('.guard-toggle-track');
1078
+ // Optimistic UI update
1079
+ if (labelEl) labelEl.textContent = newState ? 'Protected' : 'Off';
1080
+ guardRow.classList.toggle('guarded', newState);
1081
+ try {
1082
+ const res = await fetch('/api/guard', {
1083
+ method: 'POST',
1084
+ headers: { 'Content-Type': 'application/json' },
1085
+ body: JSON.stringify({ name, enabled: newState }),
1086
+ });
1087
+ if (res.ok) {
1088
+ showToast(`${newState ? 'Guard enabled' : 'Guard disabled'} for "${name}"`, 'success');
1089
+ } else {
1090
+ // Rollback
1091
+ checkbox.checked = !newState;
1092
+ if (labelEl) labelEl.textContent = !newState ? 'Protected' : 'Off';
1093
+ guardRow.classList.toggle('guarded', !newState);
1094
+ showToast('Failed to toggle guard', 'error');
1095
+ }
1096
+ } catch {
1097
+ checkbox.checked = !newState;
1098
+ if (labelEl) labelEl.textContent = !newState ? 'Protected' : 'Off';
1099
+ guardRow.classList.toggle('guarded', !newState);
1100
+ showToast('Failed to toggle guard', 'error');
1101
+ }
1102
+ await loadProcessesFresh();
1103
+ mutationUntil = Date.now() + 3000;
1104
+ });
1105
+
1106
+ // Guard restart counter (only shown when > 0)
1107
+ const extraRows: Node[] = [];
1108
+ if (proc.guardRestarts > 0) {
1109
+ extraRows.push((
1110
+ <div className="meta-item meta-restarts">
1111
+ <span className="meta-label">Guard Restarts</span>
1112
+ <span className="meta-value">
1113
+ <span className="restart-count-badge">{proc.guardRestarts}</span>
1114
+ <span className="restart-count-text">
1115
+ {proc.guardRestarts === 1 ? 'auto-restart this session' : 'auto-restarts this session'}
1116
+ </span>
1117
+ </span>
1118
+ </div>
1119
+ ) as unknown as Node);
1120
+ }
1121
+
1122
+ meta.replaceChildren(...items, guardRow, ...extraRows);
866
1123
  }
867
1124
 
868
1125
  // Reset log subtab to stdout (skip auto-refresh, we call it once below)
@@ -915,8 +1172,70 @@ export default function mount(): () => void {
915
1172
  tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
916
1173
  }
917
1174
 
918
- // Use keyed reconciler for efficient log line diffing
919
- setReconciler('keyed');
1175
+
1176
+ // ─── Build filtered indices ───
1177
+ function rebuildFilteredIndices() {
1178
+ const search = logSearch.toLowerCase();
1179
+ logFilteredIndices = [];
1180
+ for (let i = 0; i < logLinesRaw.length; i++) {
1181
+ if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1182
+ logFilteredIndices.push(i);
1183
+ }
1184
+ }
1185
+
1186
+ // ─── Render a single log line HTML string ───
1187
+ function renderLogLineHtml(rawIndex: number): string {
1188
+ const num = rawIndex + 1;
1189
+ return `<div class="log-line" data-ln="${num}"><span class="log-line-num">${num}</span><span class="log-line-content">${logLinesHtml[rawIndex]}</span></div>`;
1190
+ }
1191
+
1192
+ // ─── Virtual scroll: render only visible slice ───
1193
+ function virtualRenderSlice(logsEl: HTMLElement) {
1194
+ const count = logFilteredIndices.length;
1195
+ if (count === 0) {
1196
+ logsEl.innerHTML = '<em style="color: var(--text-muted)">No logs available</em>';
1197
+ return;
1198
+ }
1199
+
1200
+ const totalHeight = count * LOG_LINE_HEIGHT;
1201
+ const scrollTop = logsEl.scrollTop;
1202
+ const viewportH = logsEl.clientHeight;
1203
+
1204
+ // Calculate visible range with overscan
1205
+ let startIdx = Math.floor(scrollTop / LOG_LINE_HEIGHT) - LOG_OVERSCAN;
1206
+ let endIdx = Math.ceil((scrollTop + viewportH) / LOG_LINE_HEIGHT) + LOG_OVERSCAN;
1207
+ startIdx = Math.max(0, startIdx);
1208
+ endIdx = Math.min(count - 1, endIdx);
1209
+
1210
+ // Only rebuild if the visible range actually changed
1211
+ const topSpacer = logsEl.querySelector('.log-virtual-top') as HTMLElement;
1212
+ if (topSpacer && topSpacer.dataset.start === String(startIdx) && topSpacer.dataset.end === String(endIdx)) {
1213
+ return; // same range, skip DOM work
1214
+ }
1215
+
1216
+ const topH = startIdx * LOG_LINE_HEIGHT;
1217
+ const bottomH = Math.max(0, (count - endIdx - 1) * LOG_LINE_HEIGHT);
1218
+
1219
+ // Build visible lines
1220
+ const chunks: string[] = [];
1221
+ chunks.push(`<div class="log-virtual-top" data-start="${startIdx}" data-end="${endIdx}" style="height:${topH}px"></div>`);
1222
+ for (let i = startIdx; i <= endIdx; i++) {
1223
+ chunks.push(renderLogLineHtml(logFilteredIndices[i]));
1224
+ }
1225
+ chunks.push(`<div class="log-virtual-bottom" style="height:${bottomH}px"></div>`);
1226
+ logsEl.innerHTML = chunks.join('');
1227
+ }
1228
+
1229
+ // ─── Scroll handler for virtual mode ───
1230
+ function onLogScroll() {
1231
+ if (!logVirtualActive) return;
1232
+ if (logScrollRAF) return; // already scheduled
1233
+ logScrollRAF = requestAnimationFrame(() => {
1234
+ logScrollRAF = null;
1235
+ const logsEl = $('drawer-logs') as HTMLElement;
1236
+ if (logsEl) virtualRenderSlice(logsEl);
1237
+ });
1238
+ }
920
1239
 
921
1240
  function fullRebuildLogs(logsEl: HTMLElement) {
922
1241
  const search = logSearch.toLowerCase();
@@ -924,48 +1243,78 @@ export default function mount(): () => void {
924
1243
  logsEl.innerHTML = '<em style="color: var(--text-muted)">No logs available</em>';
925
1244
  updateLogCount(0);
926
1245
  logNeedsFullRebuild = false;
1246
+ logVirtualActive = false;
927
1247
  return;
928
1248
  }
929
1249
 
930
- // Build all HTML in one pass using cached ansiToHtml results
931
- const chunks: string[] = [];
932
- let count = 0;
933
- for (let i = 0; i < logLinesRaw.length; i++) {
934
- if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
935
- count++;
936
- const num = i + 1;
937
- 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>`);
938
- }
939
- logsEl.innerHTML = chunks.join('');
1250
+ // Rebuild filtered indices
1251
+ rebuildFilteredIndices();
1252
+ const count = logFilteredIndices.length;
940
1253
  updateLogCount(count);
1254
+
1255
+ // Decide: virtual or direct
1256
+ if (count >= VIRTUAL_THRESHOLD) {
1257
+ logVirtualActive = true;
1258
+ virtualRenderSlice(logsEl);
1259
+ } else {
1260
+ logVirtualActive = false;
1261
+ // Direct render — small enough for full DOM
1262
+ const chunks: string[] = [];
1263
+ for (const idx of logFilteredIndices) {
1264
+ chunks.push(renderLogLineHtml(idx));
1265
+ }
1266
+ logsEl.innerHTML = chunks.join('');
1267
+ }
941
1268
  logNeedsFullRebuild = false;
1269
+
1270
+ // Auto-calibrate line height from first rendered line
1271
+ if (!logLineHeightCalibrated) {
1272
+ requestAnimationFrame(() => calibrateLogLineHeight(logsEl));
1273
+ }
942
1274
  }
943
1275
 
944
1276
  function appendNewLogLines(logsEl: HTMLElement, startIndex: number) {
945
- // Fast path: append only new lines to existing DOM
946
1277
  const search = logSearch.toLowerCase();
947
- const fragment = document.createDocumentFragment();
948
- let count = 0;
1278
+
1279
+ // Append to filtered indices
949
1280
  for (let i = startIndex; i < logLinesRaw.length; i++) {
950
1281
  if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
951
- count++;
952
- const div = document.createElement('div');
953
- div.className = 'log-line';
954
- div.setAttribute('data-ln', String(i + 1));
955
- div.innerHTML = `<span class="log-line-num">${i + 1}</span><span class="log-line-content">${logLinesHtml[i]}</span>`;
956
- fragment.appendChild(div);
957
- }
958
- if (count > 0) logsEl.appendChild(fragment);
959
- // Update total count
960
- const total = search
961
- ? logLinesRaw.filter(l => l.toLowerCase().includes(search)).length
962
- : logLinesRaw.length;
963
- updateLogCount(total);
1282
+ logFilteredIndices.push(i);
1283
+ }
1284
+ const count = logFilteredIndices.length;
1285
+ updateLogCount(count);
1286
+
1287
+ // Check if we need to switch to virtual mode
1288
+ if (count >= VIRTUAL_THRESHOLD && !logVirtualActive) {
1289
+ logVirtualActive = true;
1290
+ virtualRenderSlice(logsEl);
1291
+ return;
1292
+ }
1293
+
1294
+ if (logVirtualActive) {
1295
+ // In virtual mode, re-render the current visible slice
1296
+ virtualRenderSlice(logsEl);
1297
+ } else {
1298
+ // Direct DOM append for small logs
1299
+ const fragment = document.createDocumentFragment();
1300
+ for (let i = startIndex; i < logLinesRaw.length; i++) {
1301
+ if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
1302
+ const div = document.createElement('div');
1303
+ div.className = 'log-line';
1304
+ div.setAttribute('data-ln', String(i + 1));
1305
+ div.innerHTML = `<span class="log-line-num">${i + 1}</span><span class="log-line-content">${logLinesHtml[i]}</span>`;
1306
+ fragment.appendChild(div);
1307
+ }
1308
+ if (fragment.childNodes.length > 0) logsEl.appendChild(fragment);
1309
+ }
964
1310
  }
965
1311
 
966
1312
  function updateLogCount(count: number) {
967
1313
  const countEl = $('log-line-count');
968
- if (countEl) countEl.textContent = `${count} line${count !== 1 ? 's' : ''}`;
1314
+ if (countEl) {
1315
+ const suffix = logVirtualActive ? ' (virtual)' : '';
1316
+ countEl.textContent = `${count} line${count !== 1 ? 's' : ''}${suffix}`;
1317
+ }
969
1318
  }
970
1319
 
971
1320
  async function refreshDrawerLogs() {
@@ -1078,6 +1427,10 @@ export default function mount(): () => void {
1078
1427
 
1079
1428
  // Click log line → expand/collapse (word-wrap toggle)
1080
1429
  const logsContainer = $('drawer-logs');
1430
+
1431
+ // Virtual scroll handler — drives re-render on scroll in virtual mode
1432
+ logsContainer?.addEventListener('scroll', onLogScroll, { passive: true });
1433
+
1081
1434
  logsContainer?.addEventListener('click', (e: Event) => {
1082
1435
  const line = (e.target as Element).closest('.log-line') as HTMLElement;
1083
1436
  if (!line) return;
@@ -1261,17 +1614,141 @@ export default function mount(): () => void {
1261
1614
  if (drawerProcess) refreshDrawerLogs();
1262
1615
  });
1263
1616
 
1617
+ // ─── Shortcuts Button ───
1618
+ $('shortcuts-btn')?.addEventListener('click', toggleShortcutsOverlay);
1619
+
1620
+ // ─── Guard All Button ───
1621
+ $('guard-all-btn')?.addEventListener('click', async () => {
1622
+ const guardAllBtn = $('guard-all-btn') as HTMLButtonElement;
1623
+ if (!guardAllBtn) return;
1624
+
1625
+ const guardable = allProcesses.filter(p => p.name !== 'bgr-dashboard');
1626
+ const guarded = guardable.filter(p => isGuarded(p)).length;
1627
+ const allGuarded = guardable.length > 0 && guarded >= guardable.length;
1628
+ const newState = !allGuarded;
1629
+
1630
+ // Disable button during operation
1631
+ guardAllBtn.disabled = true;
1632
+ guardAllBtn.style.opacity = '0.5';
1633
+
1634
+ try {
1635
+ const res = await fetch('/api/guard-all', {
1636
+ method: 'POST',
1637
+ headers: { 'Content-Type': 'application/json' },
1638
+ body: JSON.stringify({ enabled: newState }),
1639
+ });
1640
+ if (res.ok) {
1641
+ const data = await res.json();
1642
+ showToast(
1643
+ `${newState ? 'Guarded' : 'Unguarded'} ${data.count} process${data.count !== 1 ? 'es' : ''}`,
1644
+ 'success'
1645
+ );
1646
+ } else {
1647
+ const data = await res.json();
1648
+ showToast(data.error || 'Failed to toggle guard for all processes', 'error');
1649
+ }
1650
+ } catch {
1651
+ showToast('Failed to toggle guard for all processes', 'error');
1652
+ }
1653
+
1654
+ guardAllBtn.disabled = false;
1655
+ guardAllBtn.style.opacity = '';
1656
+ await loadProcessesFresh();
1657
+ mutationUntil = Date.now() + 3000;
1658
+ });
1659
+
1264
1660
  // Group toggle removed — always-on directory grouping
1265
1661
 
1266
1662
  // ─── Keyboard Shortcuts ───
1663
+ let focusedProcessName: string | null = null;
1664
+
1665
+ function getFocusableRows(): HTMLElement[] {
1666
+ const rows = tbody?.querySelectorAll('tr[data-process-name]') as NodeListOf<HTMLElement> | undefined;
1667
+ return rows ? Array.from(rows) : [];
1668
+ }
1669
+
1670
+ function setProcessFocus(name: string | null) {
1671
+ // Remove previous focus
1672
+ tbody?.querySelectorAll('tr.keyboard-focus').forEach(r => r.classList.remove('keyboard-focus'));
1673
+ focusedProcessName = name;
1674
+ if (!name) return;
1675
+ const row = tbody?.querySelector(`tr[data-process-name="${name}"]`) as HTMLElement;
1676
+ if (row) {
1677
+ row.classList.add('keyboard-focus');
1678
+ row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1679
+ }
1680
+ }
1681
+
1682
+ function navigateProcess(direction: 'up' | 'down') {
1683
+ const rows = getFocusableRows();
1684
+ if (rows.length === 0) return;
1685
+
1686
+ if (!focusedProcessName) {
1687
+ // Nothing focused: pick first or last
1688
+ const target = direction === 'down' ? rows[0] : rows[rows.length - 1];
1689
+ setProcessFocus(target.dataset.processName || null);
1690
+ return;
1691
+ }
1692
+
1693
+ const idx = rows.findIndex(r => r.dataset.processName === focusedProcessName);
1694
+ if (idx === -1) {
1695
+ setProcessFocus(rows[0].dataset.processName || null);
1696
+ return;
1697
+ }
1698
+
1699
+ const nextIdx = direction === 'down'
1700
+ ? Math.min(idx + 1, rows.length - 1)
1701
+ : Math.max(idx - 1, 0);
1702
+ setProcessFocus(rows[nextIdx].dataset.processName || null);
1703
+ }
1704
+
1705
+ /** Dispatch a process action by synthesizing a click on a virtual button */
1706
+ function dispatchAction(actionName: string, processName: string) {
1707
+ const fakeBtn = document.createElement('button');
1708
+ fakeBtn.dataset.action = actionName;
1709
+ fakeBtn.dataset.name = processName;
1710
+ // For guard toggle, read current state
1711
+ if (actionName === 'guard') {
1712
+ const proc = allProcesses.find(p => p.name === processName);
1713
+ fakeBtn.dataset.guarded = proc && isGuarded(proc) ? 'true' : 'false';
1714
+ }
1715
+ const fakeEvent = new MouseEvent('click');
1716
+ Object.defineProperty(fakeEvent, 'target', { value: fakeBtn });
1717
+ handleAction(fakeEvent);
1718
+ }
1719
+
1720
+ function toggleShortcutsOverlay() {
1721
+ const overlay = $('shortcuts-overlay');
1722
+ if (overlay) overlay.classList.toggle('active');
1723
+ }
1724
+
1725
+ $('shortcuts-close-btn')?.addEventListener('click', () => {
1726
+ $('shortcuts-overlay')?.classList.remove('active');
1727
+ });
1728
+ $('shortcuts-overlay')?.addEventListener('click', (e) => {
1729
+ if ((e.target as Element).classList.contains('shortcuts-overlay')) {
1730
+ $('shortcuts-overlay')?.classList.remove('active');
1731
+ }
1732
+ });
1733
+
1267
1734
  function handleKeydown(e: KeyboardEvent) {
1735
+ // Skip all shortcuts when inside text inputs or textareas
1736
+ const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
1737
+
1268
1738
  // "/" to focus search (unless already in an input)
1269
- if (e.key === '/' && !(e.target instanceof HTMLInputElement)) {
1739
+ if (e.key === '/' && !inInput) {
1270
1740
  e.preventDefault();
1271
1741
  searchInput?.focus();
1272
1742
  return;
1273
1743
  }
1744
+
1745
+ // Escape: close overlays progressively
1274
1746
  if (e.key === 'Escape') {
1747
+ const shortcutsOverlay = $('shortcuts-overlay');
1748
+ if (shortcutsOverlay?.classList.contains('active')) {
1749
+ shortcutsOverlay.classList.remove('active');
1750
+ return;
1751
+ }
1275
1752
  if (contextMenuEl) {
1276
1753
  closeContextMenu();
1277
1754
  return;
@@ -1281,10 +1758,75 @@ export default function mount(): () => void {
1281
1758
  } else {
1282
1759
  closeModal();
1283
1760
  }
1761
+ // Clear keyboard focus
1762
+ setProcessFocus(null);
1284
1763
  // Blur search on escape
1285
1764
  if (document.activeElement === searchInput) {
1286
1765
  searchInput?.blur();
1287
1766
  }
1767
+ return;
1768
+ }
1769
+
1770
+ // Remaining shortcuts only when NOT in inputs
1771
+ if (inInput) return;
1772
+
1773
+ // Arrow navigation
1774
+ if (e.key === 'ArrowDown' || e.key === 'j') {
1775
+ e.preventDefault();
1776
+ navigateProcess('down');
1777
+ return;
1778
+ }
1779
+ if (e.key === 'ArrowUp' || e.key === 'k') {
1780
+ e.preventDefault();
1781
+ navigateProcess('up');
1782
+ return;
1783
+ }
1784
+
1785
+ // Enter: open drawer for focused process
1786
+ if (e.key === 'Enter' && focusedProcessName) {
1787
+ e.preventDefault();
1788
+ openDrawer(focusedProcessName);
1789
+ return;
1790
+ }
1791
+
1792
+ // ? — help overlay
1793
+ if (e.key === '?') {
1794
+ e.preventDefault();
1795
+ toggleShortcutsOverlay();
1796
+ return;
1797
+ }
1798
+
1799
+ // N — new process modal
1800
+ if (e.key === 'n' || e.key === 'N') {
1801
+ e.preventDefault();
1802
+ openModal();
1803
+ return;
1804
+ }
1805
+
1806
+ // Process actions — require a focused row
1807
+ if (!focusedProcessName) return;
1808
+
1809
+ if (e.key === 'r' || e.key === 'R') {
1810
+ e.preventDefault();
1811
+ dispatchAction('restart', focusedProcessName);
1812
+ return;
1813
+ }
1814
+ if (e.key === 's' || e.key === 'S') {
1815
+ e.preventDefault();
1816
+ dispatchAction('stop', focusedProcessName);
1817
+ return;
1818
+ }
1819
+ if (e.key === 'g' || e.key === 'G') {
1820
+ e.preventDefault();
1821
+ dispatchAction('guard', focusedProcessName);
1822
+ return;
1823
+ }
1824
+ if (e.key === 'd' || e.key === 'D') {
1825
+ e.preventDefault();
1826
+ dispatchAction('delete', focusedProcessName);
1827
+ // Clear focus since process is gone
1828
+ setProcessFocus(null);
1829
+ return;
1288
1830
  }
1289
1831
  }
1290
1832
  document.addEventListener('keydown', handleKeydown);
@@ -1293,10 +1835,17 @@ export default function mount(): () => void {
1293
1835
  let eventSource: EventSource | null = null;
1294
1836
  let logRefreshTimer: ReturnType<typeof setInterval> | null = null;
1295
1837
  let sseThrottleTimer: ReturnType<typeof setTimeout> | null = null;
1838
+ let sseRetryDelay = 2_000; // exponential backoff start
1839
+ const SSE_MAX_RETRY = 30_000; // max 30s between retries
1840
+
1841
+ // Initial data load — don't depend on SSE for first render
1842
+ loadProcesses();
1296
1843
 
1297
1844
  function connectSSE() {
1845
+ if (eventSource) { eventSource.close(); eventSource = null; }
1298
1846
  eventSource = new EventSource('/api/events');
1299
1847
  eventSource.onmessage = (event) => {
1848
+ sseRetryDelay = 2_000; // reset backoff on success
1300
1849
  // Skip SSE updates briefly after mutations to avoid flicker
1301
1850
  if (Date.now() < mutationUntil) return;
1302
1851
  try {
@@ -1312,14 +1861,29 @@ export default function mount(): () => void {
1312
1861
  } catch { /* invalid data, skip */ }
1313
1862
  };
1314
1863
  eventSource.onerror = () => {
1315
- // SSE disconnected, reconnect after 5s
1864
+ // SSE disconnected exponential backoff reconnect
1316
1865
  eventSource?.close();
1317
1866
  eventSource = null;
1318
- setTimeout(connectSSE, 5000);
1867
+ setTimeout(connectSSE, sseRetryDelay);
1868
+ sseRetryDelay = Math.min(sseRetryDelay * 2, SSE_MAX_RETRY);
1319
1869
  };
1320
1870
  }
1321
1871
  connectSSE();
1322
1872
 
1873
+ // Pause SSE when tab is hidden, resume when visible
1874
+ function handleVisibility() {
1875
+ if (document.hidden) {
1876
+ eventSource?.close();
1877
+ eventSource = null;
1878
+ } else {
1879
+ if (!eventSource) {
1880
+ sseRetryDelay = 2_000; // reset on manual re-focus
1881
+ connectSSE();
1882
+ }
1883
+ }
1884
+ }
1885
+ document.addEventListener('visibilitychange', handleVisibility);
1886
+
1323
1887
  // Log drawer still needs periodic refresh (not part of SSE)
1324
1888
  logRefreshTimer = setInterval(() => {
1325
1889
  if (drawerProcess) refreshDrawerLogs();
@@ -1335,9 +1899,13 @@ export default function mount(): () => void {
1335
1899
  $('modal-create-btn')?.removeEventListener('click', createProcess);
1336
1900
  $('refresh-btn')?.removeEventListener('click', loadProcesses);
1337
1901
  document.removeEventListener('keydown', handleKeydown);
1902
+ document.removeEventListener('visibilitychange', handleVisibility);
1338
1903
  closeContextMenu();
1339
1904
  if (eventSource) eventSource.close();
1340
1905
  if (logRefreshTimer) clearInterval(logRefreshTimer);
1341
1906
  if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
1907
+ if (searchDebounce) clearTimeout(searchDebounce);
1908
+ if (logScrollRAF) cancelAnimationFrame(logScrollRAF);
1909
+ logsContainer?.removeEventListener('scroll', onLogScroll);
1342
1910
  };
1343
1911
  }