bgrun 3.10.2 → 3.11.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,4 +1,4 @@
1
- /** @jsxImportSource react */
1
+ /** @jsxImportSource melina/client */
2
2
  /**
3
3
  * bgrun Dashboard — Page Client Interactivity
4
4
  *
@@ -26,6 +26,10 @@ interface ProcessData {
26
26
  configPath: string;
27
27
  stdoutPath: string;
28
28
  stderrPath: string;
29
+ guardRestarts: number;
30
+ cpu?: number;
31
+ cpuHistory?: number[];
32
+ memoryHistory?: number[];
29
33
  }
30
34
 
31
35
  // ─── SVG Icon Helpers ───
@@ -94,6 +98,51 @@ function DeployIcon() {
94
98
  );
95
99
  }
96
100
 
101
+ function ShieldIcon() {
102
+ return (
103
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
104
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
105
+ </svg>
106
+ );
107
+ }
108
+
109
+ // ─── Sparkline Component ───
110
+ function MiniSparkline({ data, height = 24, stroke = "var(--text-accent)" }: { data: number[], height?: number, stroke?: string }) {
111
+ if (!data || data.length < 2) return <svg className="sparkline" viewBox="0 0 100 24" height={height} width="60" />;
112
+
113
+ const max = Math.max(...data, 1);
114
+ const min = Math.min(...data, 0); // anchor to 0 minimum
115
+ const range = max - min || 1;
116
+ const padding = 2; // top/bottom pixel padding
117
+
118
+ // Normalize path across 100 viewBox width
119
+ const path = data.map((val, i) => {
120
+ const x = (i / (data.length - 1)) * 100;
121
+ const normalizedY = ((val - min) / range);
122
+ const y = padding + (1 - normalizedY) * (24 - padding * 2);
123
+ return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
124
+ }).join(' ');
125
+
126
+ return (
127
+ <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 }}>
128
+ <path d={path} />
129
+ </svg>
130
+ );
131
+ }
132
+
133
+ // ─── Guard Helper ───
134
+
135
+ function isGuarded(p: ProcessData): boolean {
136
+ if (!p.env) return false;
137
+ // env is comma-separated "KEY=VAL,KEY2=VAL2" or JSON string
138
+ try {
139
+ const parsed = JSON.parse(p.env);
140
+ return parsed.BGR_KEEP_ALIVE === 'true';
141
+ } catch {
142
+ return p.env.includes('BGR_KEEP_ALIVE=true');
143
+ }
144
+ }
145
+
97
146
  // ─── Utility: Format Runtime ───
98
147
 
99
148
  function formatRuntime(raw: string): string {
@@ -148,11 +197,22 @@ function shortenPath(dir: string): string {
148
197
  // ─── JSX Components ───
149
198
 
150
199
  function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
200
+ const guarded = isGuarded(p);
151
201
  return (
152
202
  <tr data-process-name={p.name} className={animate ? 'animate-in' : ''} style={animate ? { opacity: '0' } : undefined}>
153
203
  <td>
154
204
  <div className="process-name">
155
205
  <span>{p.name}</span>
206
+ <button
207
+ className={`guard-toggle ${guarded ? 'guarded' : ''}`}
208
+ data-action="guard"
209
+ data-name={p.name}
210
+ data-guarded={guarded ? 'true' : 'false'}
211
+ title={guarded ? 'Process is guarded — click to disable auto-restart' : 'Click to enable auto-restart guard'}
212
+ onClick={(e: Event) => e.stopPropagation()}
213
+ >
214
+ <ShieldIcon />
215
+ </button>
156
216
  </div>
157
217
  </td>
158
218
  <td>
@@ -168,14 +228,26 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
168
228
  : <span style={{ color: 'var(--text-muted)' }}>–</span>
169
229
  }
170
230
  </td>
231
+ <td className="cpu">
232
+ {p.running && (p.cpu !== undefined)
233
+ ? <div className="metrics-cell">
234
+ <span>{p.cpu > 0 ? `${p.cpu.toFixed(1)}%` : '<0.1%'}</span>
235
+ <MiniSparkline data={p.cpuHistory || []} stroke="#7ee787" />
236
+ </div>
237
+ : <span style={{ color: 'var(--text-muted)' }}>–</span>
238
+ }
239
+ </td>
171
240
  <td className="memory">
172
- {p.memory > 0
173
- ? <span className="memory-badge">{formatMemory(p.memory)}</span>
241
+ {p.running && p.memory > 0
242
+ ? <div className="metrics-cell">
243
+ <span className="memory-badge">{formatMemory(p.memory)}</span>
244
+ <MiniSparkline data={p.memoryHistory || []} stroke="#a5d6ff" />
245
+ </div>
174
246
  : <span style={{ color: 'var(--text-muted)' }}>–</span>
175
247
  }
176
248
  </td>
177
249
  <td className="command" title={p.command}>{p.command}</td>
178
- <td className="runtime">{formatRuntime(p.runtime)}</td>
250
+ <td className="runtime">{formatRuntime(String(p.runtime))}</td>
179
251
  </tr>
180
252
  );
181
253
  }
@@ -215,11 +287,13 @@ function EmptyState() {
215
287
  }
216
288
 
217
289
  function ProcessCard({ p }: { p: ProcessData }) {
290
+ const guarded = isGuarded(p);
218
291
  return (
219
292
  <div className="process-card" data-process-name={p.name}>
220
293
  <div className="card-header">
221
294
  <div className="process-name">
222
295
  <span>{p.name}</span>
296
+ {guarded && <span className="guard-badge" title="Auto-restart enabled">🛡️</span>}
223
297
  </div>
224
298
  <span className={`status-badge ${p.running ? 'running' : 'stopped'}`}>
225
299
  <span className="status-dot"></span>
@@ -229,11 +303,15 @@ function ProcessCard({ p }: { p: ProcessData }) {
229
303
  <div className="card-details">
230
304
  <div className="card-detail"><span className="card-label">PID</span><span>{p.pid}</span></div>
231
305
  <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>
306
+ <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>
307
+ <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>
308
+ <div className="card-detail"><span className="card-label">Runtime</span><span>{formatRuntime(String(p.runtime))}</span></div>
234
309
  </div>
235
310
  <div className="card-command" title={p.command}>{p.command}</div>
236
311
  <div className="card-actions">
312
+ <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'}>
313
+ <ShieldIcon /> {guarded ? 'Unguard' : 'Guard'}
314
+ </button>
237
315
  <button className="action-btn info" data-action="logs" data-name={p.name} title="View Logs">
238
316
  <LogsIcon /> Logs
239
317
  </button>
@@ -401,16 +479,51 @@ export default function mount(): () => void {
401
479
  const total = processes.length;
402
480
  const running = processes.filter(p => p.running).length;
403
481
  const stopped = total - running;
482
+ const guarded = processes.filter(p => isGuarded(p)).length;
483
+ const guardable = processes.filter(p => p.name !== 'bgr-dashboard').length;
404
484
  const totalMemory = processes.reduce((sum, p) => sum + (p.memory || 0), 0);
405
485
 
406
486
  const tc = $('total-count');
407
487
  const rc = $('running-count');
408
488
  const sc = $('stopped-count');
489
+ const gc = $('guarded-count');
409
490
  const mc = $('memory-count');
491
+ const rrc = $('restarts-count');
410
492
  if (tc) tc.textContent = String(total);
411
493
  if (rc) rc.textContent = String(running);
412
494
  if (sc) sc.textContent = String(stopped);
495
+ if (gc) gc.textContent = String(guarded);
413
496
  if (mc) mc.textContent = formatMemory(totalMemory) || '0 MB';
497
+ const totalRestarts = processes.reduce((sum, p) => sum + (p.guardRestarts || 0), 0);
498
+ if (rrc) rrc.textContent = String(totalRestarts);
499
+
500
+ // Update Guard All button state
501
+ const guardAllBtn = $('guard-all-btn');
502
+ const guardAllLabel = $('guard-all-label');
503
+ if (guardAllBtn && guardAllLabel) {
504
+ const allGuarded = guardable > 0 && guarded >= guardable;
505
+ guardAllBtn.classList.toggle('all-guarded', allGuarded);
506
+ guardAllLabel.textContent = allGuarded ? 'Unguard All' : 'Guard All';
507
+ guardAllBtn.title = allGuarded ? 'Remove guard from all processes' : 'Guard all processes (auto-restart on crash)';
508
+ }
509
+
510
+ // Update guard sentinel pill
511
+ const guardPill = $('guard-sentinel-pill');
512
+ const guardLabel = $('guard-sentinel-label');
513
+ if (guardPill && guardLabel) {
514
+ const guardProc = processes.find(p => p.name === 'bgr-guard');
515
+ guardPill.classList.remove('active', 'stopped');
516
+ if (guardProc && guardProc.running) {
517
+ guardPill.classList.add('active');
518
+ const restarts = guardProc.guardRestarts || 0;
519
+ guardLabel.textContent = restarts > 0 ? `Guard: ON (${restarts}↻)` : 'Guard: ON';
520
+ } else if (guardProc) {
521
+ guardPill.classList.add('stopped');
522
+ guardLabel.textContent = 'Guard: OFF';
523
+ } else {
524
+ guardLabel.textContent = 'Guard: –';
525
+ }
526
+ }
414
527
  }
415
528
 
416
529
  function renderProcesses(processes: ProcessData[]) {
@@ -608,6 +721,29 @@ export default function mount(): () => void {
608
721
  break;
609
722
  }
610
723
 
724
+ case 'guard': {
725
+ const currentlyGuarded = btn.dataset.guarded === 'true';
726
+ const newState = !currentlyGuarded;
727
+ try {
728
+ const res = await fetch('/api/guard', {
729
+ method: 'POST',
730
+ headers: { 'Content-Type': 'application/json' },
731
+ body: JSON.stringify({ name, enabled: newState }),
732
+ });
733
+ if (res.ok) {
734
+ showToast(`${newState ? 'Guarded' : 'Unguarded'} "${name}"`, 'success');
735
+ } else {
736
+ const data = await res.json();
737
+ showToast(data.error || `Failed to toggle guard for "${name}"`, 'error');
738
+ }
739
+ } catch {
740
+ showToast(`Failed to toggle guard for "${name}"`, 'error');
741
+ }
742
+ await loadProcessesFresh();
743
+ mutationUntil = Date.now() + 3000;
744
+ break;
745
+ }
746
+
611
747
  case 'logs':
612
748
  openDrawer(name);
613
749
  break;
@@ -631,12 +767,17 @@ export default function mount(): () => void {
631
767
  const proc = allProcesses.find(p => p.name === name);
632
768
  if (!proc) return;
633
769
 
770
+ const guarded = isGuarded(proc);
634
771
  const menu = (
635
772
  <div className="context-menu" style={{ left: `${x}px`, top: `${y}px` }}>
636
773
  <button className="context-item" data-action="logs" data-name={name}>
637
774
  <LogsIcon /> View Logs
638
775
  </button>
639
776
  <div className="context-divider"></div>
777
+ <button className={`context-item ${guarded ? 'guard-active' : 'guard'}`} data-action="guard" data-name={name} data-guarded={guarded ? 'true' : 'false'}>
778
+ <ShieldIcon /> {guarded ? 'Disable Guard' : 'Enable Guard'}
779
+ </button>
780
+ <div className="context-divider"></div>
640
781
  {proc.running
641
782
  ? <button className="context-item danger" data-action="stop" data-name={name}>
642
783
  <StopIcon /> Stop
@@ -842,11 +983,12 @@ export default function mount(): () => void {
842
983
  const proc = allProcesses.find(p => p.name === name);
843
984
  const meta = $('drawer-meta');
844
985
  if (meta && proc) {
986
+ const guarded = isGuarded(proc);
845
987
  const metaItems = [
846
988
  { label: 'Status', value: proc.running ? '● Running' : '○ Stopped' },
847
989
  { label: 'PID', value: String(proc.pid) },
848
990
  { label: 'Port', value: proc.port ? `:${proc.port}` : '–', href: proc.port ? `http://localhost:${proc.port}` : undefined },
849
- { label: 'Runtime', value: formatRuntime(proc.runtime) },
991
+ { label: 'Runtime', value: formatRuntime(String(proc.runtime)) },
850
992
  { label: 'Command', value: proc.command },
851
993
  { label: 'Directory', value: proc.directory || '–' },
852
994
  { label: 'Memory', value: formatMemory(proc.memory) },
@@ -862,7 +1004,74 @@ export default function mount(): () => void {
862
1004
  }
863
1005
  </div>
864
1006
  ) as unknown as Node);
865
- meta.replaceChildren(...items);
1007
+
1008
+ // Guard toggle row with inline switch
1009
+ const guardRow = (
1010
+ <div className={`meta-item meta-guard ${guarded ? 'guarded' : ''}`}>
1011
+ <span className="meta-label">
1012
+ <ShieldIcon /> Guard
1013
+ </span>
1014
+ <label className="guard-toggle" title={guarded ? 'Auto-restart is ON — click to disable' : 'Auto-restart is OFF — click to enable'}>
1015
+ <input type="checkbox" checked={guarded} className="guard-toggle-input" />
1016
+ <span className="guard-toggle-track">
1017
+ <span className="guard-toggle-thumb"></span>
1018
+ </span>
1019
+ <span className="guard-toggle-label">{guarded ? 'Protected' : 'Off'}</span>
1020
+ </label>
1021
+ </div>
1022
+ ) as unknown as HTMLElement;
1023
+
1024
+ // Wire toggle click
1025
+ const checkbox = guardRow.querySelector('.guard-toggle-input') as HTMLInputElement;
1026
+ checkbox?.addEventListener('change', async () => {
1027
+ const newState = checkbox.checked;
1028
+ const labelEl = guardRow.querySelector('.guard-toggle-label');
1029
+ const trackEl = guardRow.querySelector('.guard-toggle-track');
1030
+ // Optimistic UI update
1031
+ if (labelEl) labelEl.textContent = newState ? 'Protected' : 'Off';
1032
+ guardRow.classList.toggle('guarded', newState);
1033
+ try {
1034
+ const res = await fetch('/api/guard', {
1035
+ method: 'POST',
1036
+ headers: { 'Content-Type': 'application/json' },
1037
+ body: JSON.stringify({ name, enabled: newState }),
1038
+ });
1039
+ if (res.ok) {
1040
+ showToast(`${newState ? 'Guard enabled' : 'Guard disabled'} for "${name}"`, 'success');
1041
+ } else {
1042
+ // Rollback
1043
+ checkbox.checked = !newState;
1044
+ if (labelEl) labelEl.textContent = !newState ? 'Protected' : 'Off';
1045
+ guardRow.classList.toggle('guarded', !newState);
1046
+ showToast('Failed to toggle guard', 'error');
1047
+ }
1048
+ } catch {
1049
+ checkbox.checked = !newState;
1050
+ if (labelEl) labelEl.textContent = !newState ? 'Protected' : 'Off';
1051
+ guardRow.classList.toggle('guarded', !newState);
1052
+ showToast('Failed to toggle guard', 'error');
1053
+ }
1054
+ await loadProcessesFresh();
1055
+ mutationUntil = Date.now() + 3000;
1056
+ });
1057
+
1058
+ // Guard restart counter (only shown when > 0)
1059
+ const extraRows: Node[] = [];
1060
+ if (proc.guardRestarts > 0) {
1061
+ extraRows.push((
1062
+ <div className="meta-item meta-restarts">
1063
+ <span className="meta-label">Guard Restarts</span>
1064
+ <span className="meta-value">
1065
+ <span className="restart-count-badge">{proc.guardRestarts}</span>
1066
+ <span className="restart-count-text">
1067
+ {proc.guardRestarts === 1 ? 'auto-restart this session' : 'auto-restarts this session'}
1068
+ </span>
1069
+ </span>
1070
+ </div>
1071
+ ) as unknown as Node);
1072
+ }
1073
+
1074
+ meta.replaceChildren(...items, guardRow, ...extraRows);
866
1075
  }
867
1076
 
868
1077
  // Reset log subtab to stdout (skip auto-refresh, we call it once below)
@@ -1261,6 +1470,46 @@ export default function mount(): () => void {
1261
1470
  if (drawerProcess) refreshDrawerLogs();
1262
1471
  });
1263
1472
 
1473
+ // ─── Guard All Button ───
1474
+ $('guard-all-btn')?.addEventListener('click', async () => {
1475
+ const guardAllBtn = $('guard-all-btn') as HTMLButtonElement;
1476
+ if (!guardAllBtn) return;
1477
+
1478
+ const guardable = allProcesses.filter(p => p.name !== 'bgr-dashboard');
1479
+ const guarded = guardable.filter(p => isGuarded(p)).length;
1480
+ const allGuarded = guardable.length > 0 && guarded >= guardable.length;
1481
+ const newState = !allGuarded;
1482
+
1483
+ // Disable button during operation
1484
+ guardAllBtn.disabled = true;
1485
+ guardAllBtn.style.opacity = '0.5';
1486
+
1487
+ try {
1488
+ const res = await fetch('/api/guard-all', {
1489
+ method: 'POST',
1490
+ headers: { 'Content-Type': 'application/json' },
1491
+ body: JSON.stringify({ enabled: newState }),
1492
+ });
1493
+ if (res.ok) {
1494
+ const data = await res.json();
1495
+ showToast(
1496
+ `${newState ? 'Guarded' : 'Unguarded'} ${data.count} process${data.count !== 1 ? 'es' : ''}`,
1497
+ 'success'
1498
+ );
1499
+ } else {
1500
+ const data = await res.json();
1501
+ showToast(data.error || 'Failed to toggle guard for all processes', 'error');
1502
+ }
1503
+ } catch {
1504
+ showToast('Failed to toggle guard for all processes', 'error');
1505
+ }
1506
+
1507
+ guardAllBtn.disabled = false;
1508
+ guardAllBtn.style.opacity = '';
1509
+ await loadProcessesFresh();
1510
+ mutationUntil = Date.now() + 3000;
1511
+ });
1512
+
1264
1513
  // Group toggle removed — always-on directory grouping
1265
1514
 
1266
1515
  // ─── Keyboard Shortcuts ───
@@ -25,10 +25,18 @@ export default function DashboardPage() {
25
25
  <div className="stat-label">Stopped</div>
26
26
  <div className="stat-value" id="stopped-count">–</div>
27
27
  </div>
28
+ <div className="stat-card guarded">
29
+ <div className="stat-label">Guarded</div>
30
+ <div className="stat-value" id="guarded-count">–</div>
31
+ </div>
28
32
  <div className="stat-card memory">
29
33
  <div className="stat-label">Total Memory</div>
30
34
  <div className="stat-value" id="memory-count">–</div>
31
35
  </div>
36
+ <div className="stat-card restarts">
37
+ <div className="stat-label">Guard Restarts</div>
38
+ <div className="stat-value" id="restarts-count">0</div>
39
+ </div>
32
40
  </div>
33
41
 
34
42
  {/* Toolbar */}
@@ -55,6 +63,16 @@ export default function DashboardPage() {
55
63
  <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
56
64
  </svg>
57
65
  </button>
66
+ <button className="btn btn-ghost btn-guard-all" id="guard-all-btn" title="Guard All Processes">
67
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
68
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
69
+ </svg>
70
+ <span id="guard-all-label">Guard All</span>
71
+ </button>
72
+ <span className="guard-sentinel-pill" id="guard-sentinel-pill" title="Standalone guard process status">
73
+ <span className="guard-sentinel-dot" id="guard-sentinel-dot" />
74
+ <span id="guard-sentinel-label">Guard: –</span>
75
+ </span>
58
76
  <button className="btn btn-primary" id="new-process-btn">
59
77
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
60
78
  <line x1="12" y1="5" x2="12" y2="19" />
@@ -74,7 +92,8 @@ export default function DashboardPage() {
74
92
  <th style={{ width: '90px' }}>Status</th>
75
93
  <th style={{ width: '70px' }}>PID</th>
76
94
  <th style={{ width: '70px' }}>Port</th>
77
- <th style={{ width: '70px' }}>Memory</th>
95
+ <th style={{ width: '80px' }}>CPU</th>
96
+ <th style={{ width: '120px' }}>Memory</th>
78
97
  <th>Command</th>
79
98
  <th style={{ width: '100px' }}>Runtime</th>
80
99
  </tr>