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.
- package/README.md +84 -4
- package/dashboard/app/api/deps/route.ts +49 -0
- package/dashboard/app/api/events/route.ts +10 -2
- package/dashboard/app/api/guard/route.ts +1 -1
- package/dashboard/app/api/guard-all/route.ts +50 -0
- package/dashboard/app/api/logs/rotate/route.ts +45 -0
- package/dashboard/app/api/processes/route.ts +67 -10
- package/dashboard/app/globals.css +547 -6
- package/dashboard/app/page.client.tsx +636 -68
- package/dashboard/app/page.tsx +52 -1
- package/dist/index.js +452 -36
- package/package.json +62 -60
- package/scripts/bgr-startup.ps1 +118 -0
- package/src/api.ts +3 -3
- package/src/bgrun.test.ts +109 -0
- package/src/commands/list.ts +3 -3
- package/src/commands/run.ts +17 -0
- package/src/deps.ts +126 -0
- package/src/guard.ts +157 -0
- package/src/index.ts +108 -3
- package/src/log-rotation.ts +93 -0
- package/src/logger.ts +4 -3
- package/src/platform.ts +39 -23
- package/src/server.ts +43 -3
- package/src/table.ts +3 -3
|
@@ -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
|
|
7
|
-
*
|
|
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
|
-
? <
|
|
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">
|
|
233
|
-
<div className="card-detail"><span className="card-label">
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
610
|
+
rows.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
|
|
455
611
|
});
|
|
456
612
|
}
|
|
457
613
|
});
|
|
458
614
|
|
|
459
|
-
tbody
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
635
|
+
// Restore selected row + keyboard focus row
|
|
487
636
|
if (drawerProcess) {
|
|
488
|
-
const
|
|
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
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
919
|
-
|
|
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
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
948
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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)
|
|
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 === '/' && !
|
|
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
|
|
1864
|
+
// SSE disconnected — exponential backoff reconnect
|
|
1316
1865
|
eventSource?.close();
|
|
1317
1866
|
eventSource = null;
|
|
1318
|
-
setTimeout(connectSSE,
|
|
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
|
}
|