bgrun 3.11.0 → 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 +8 -2
- package/dashboard/app/api/events/route.ts +10 -2
- package/dashboard/app/globals.css +161 -0
- package/dashboard/app/page.client.tsx +380 -61
- package/dashboard/app/page.tsx +32 -0
- package/package.json +3 -2
- package/scripts/bgr-startup.ps1 +118 -0
- package/src/bgrun.test.ts +109 -0
- package/src/table.ts +3 -3
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
**Production-ready process manager with dashboard and programmatic API, designed for running your containers, services, and AI agents.**
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/bgrun)
|
|
8
|
+
[](https://github.com/Mements/bgr/actions/workflows/ci.yml)
|
|
8
9
|
[](https://bun.sh/)
|
|
9
10
|
[](./LICENSE)
|
|
10
11
|
|
|
@@ -74,11 +75,16 @@ bgrun.yourdomain.com {
|
|
|
74
75
|
Features:
|
|
75
76
|
- Real-time process status via SSE (no polling)
|
|
76
77
|
- Start, stop, restart, and delete processes from the UI
|
|
77
|
-
- Live stdout/stderr log viewer with search
|
|
78
|
+
- Live stdout/stderr log viewer with search and virtual scrolling
|
|
78
79
|
- Memory, PID, port, runtime, and guard restarts at a glance
|
|
79
80
|
- Guard toggle per-process (auto-restart on crash)
|
|
80
|
-
-
|
|
81
|
+
- Keyboard shortcuts — `↑/↓` or `j/k` navigate, `Enter` open, `R` restart, `S` stop, `G` guard, `D` delete, `N` new, `?` help
|
|
82
|
+
- Search with debounce, result count badge, and persistence across SSE updates
|
|
83
|
+
- Auto-calibrated virtual scroll for large log files (10K+ lines)
|
|
84
|
+
- Dark / light theme toggle
|
|
85
|
+
- Responsive mobile layout with cards view
|
|
81
86
|
- Collapsible directory groups
|
|
87
|
+
- Right-click context menu on process rows
|
|
82
88
|
|
|
83
89
|
---
|
|
84
90
|
|
|
@@ -26,7 +26,12 @@ export async function GET(req: Request) {
|
|
|
26
26
|
controller.enqueue(encoder.encode(`data: []\n\n`));
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Periodic keepalive to prevent proxy/browser timeouts
|
|
30
|
+
const keepaliveInterval = setInterval(() => {
|
|
31
|
+
try {
|
|
32
|
+
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
|
33
|
+
} catch { /* stream closed */ }
|
|
34
|
+
}, 15_000);
|
|
30
35
|
|
|
31
36
|
// Then send updates every INTERVAL_MS
|
|
32
37
|
const interval = setInterval(async () => {
|
|
@@ -40,7 +45,10 @@ export async function GET(req: Request) {
|
|
|
40
45
|
}, INTERVAL_MS);
|
|
41
46
|
|
|
42
47
|
// Store cleanup for when the stream is cancelled
|
|
43
|
-
(stream as any).__cleanup = () =>
|
|
48
|
+
(stream as any).__cleanup = () => {
|
|
49
|
+
clearInterval(interval);
|
|
50
|
+
clearInterval(keepaliveInterval);
|
|
51
|
+
};
|
|
44
52
|
},
|
|
45
53
|
cancel() {
|
|
46
54
|
if ((stream as any).__cleanup) {
|
|
@@ -675,6 +675,22 @@ body::after {
|
|
|
675
675
|
opacity: 0;
|
|
676
676
|
}
|
|
677
677
|
|
|
678
|
+
.search-count {
|
|
679
|
+
position: absolute;
|
|
680
|
+
right: 2rem;
|
|
681
|
+
top: 50%;
|
|
682
|
+
transform: translateY(-50%);
|
|
683
|
+
padding: 1px 6px;
|
|
684
|
+
font-size: 0.6rem;
|
|
685
|
+
font-family: var(--font-mono);
|
|
686
|
+
color: var(--accent);
|
|
687
|
+
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
|
688
|
+
border-radius: 8px;
|
|
689
|
+
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
|
690
|
+
pointer-events: none;
|
|
691
|
+
letter-spacing: 0.3px;
|
|
692
|
+
}
|
|
693
|
+
|
|
678
694
|
/* ─── Buttons ─── */
|
|
679
695
|
.btn {
|
|
680
696
|
display: inline-flex;
|
|
@@ -2040,6 +2056,14 @@ a.port-link:hover {
|
|
|
2040
2056
|
white-space: nowrap;
|
|
2041
2057
|
}
|
|
2042
2058
|
|
|
2059
|
+
/* ─── Virtual Scroll Spacers ─── */
|
|
2060
|
+
.log-virtual-top,
|
|
2061
|
+
.log-virtual-bottom {
|
|
2062
|
+
display: block;
|
|
2063
|
+
width: 100%;
|
|
2064
|
+
pointer-events: none;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2043
2067
|
/* ─── Log Lines ─── */
|
|
2044
2068
|
.log-line {
|
|
2045
2069
|
padding: 0.1rem 0.5rem 0.1rem 0;
|
|
@@ -2446,4 +2470,141 @@ a.port-link:hover {
|
|
|
2446
2470
|
.guard-sentinel-pill.stopped .guard-sentinel-dot {
|
|
2447
2471
|
background: var(--danger);
|
|
2448
2472
|
box-shadow: 0 0 4px rgba(239, 68, 68, 0.3);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/* ─── Keyboard Navigation Focus ─── */
|
|
2476
|
+
tr.keyboard-focus {
|
|
2477
|
+
outline: 2px solid var(--accent-primary);
|
|
2478
|
+
outline-offset: -2px;
|
|
2479
|
+
background: rgba(168, 85, 247, 0.08) !important;
|
|
2480
|
+
border-radius: var(--radius-sm);
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
tr.keyboard-focus td:first-child .process-name span {
|
|
2484
|
+
color: var(--accent-primary);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/* ─── Keyboard Shortcuts Overlay ─── */
|
|
2488
|
+
.shortcuts-overlay {
|
|
2489
|
+
position: fixed;
|
|
2490
|
+
inset: 0;
|
|
2491
|
+
z-index: 2000;
|
|
2492
|
+
background: rgba(0, 0, 0, 0.6);
|
|
2493
|
+
backdrop-filter: blur(4px);
|
|
2494
|
+
display: flex;
|
|
2495
|
+
align-items: center;
|
|
2496
|
+
justify-content: center;
|
|
2497
|
+
opacity: 0;
|
|
2498
|
+
pointer-events: none;
|
|
2499
|
+
transition: opacity 0.2s ease;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
.shortcuts-overlay.active {
|
|
2503
|
+
opacity: 1;
|
|
2504
|
+
pointer-events: auto;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
.shortcuts-panel {
|
|
2508
|
+
background: var(--bg-secondary);
|
|
2509
|
+
border: 1px solid var(--border-glass);
|
|
2510
|
+
border-radius: var(--radius-lg);
|
|
2511
|
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5), 0 0 60px rgba(0, 0, 0, 0.3);
|
|
2512
|
+
padding: 1.5rem;
|
|
2513
|
+
min-width: 440px;
|
|
2514
|
+
max-width: 520px;
|
|
2515
|
+
animation: scaleIn 0.2s ease;
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
.shortcuts-overlay.active .shortcuts-panel {
|
|
2519
|
+
animation: scaleIn 0.2s ease;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
@keyframes scaleIn {
|
|
2523
|
+
from {
|
|
2524
|
+
transform: scale(0.95);
|
|
2525
|
+
opacity: 0;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
to {
|
|
2529
|
+
transform: scale(1);
|
|
2530
|
+
opacity: 1;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
.shortcuts-header {
|
|
2535
|
+
display: flex;
|
|
2536
|
+
align-items: center;
|
|
2537
|
+
justify-content: space-between;
|
|
2538
|
+
margin-bottom: 1.25rem;
|
|
2539
|
+
padding-bottom: 0.75rem;
|
|
2540
|
+
border-bottom: 1px solid var(--border-glass);
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
.shortcuts-header h3 {
|
|
2544
|
+
font-size: 1rem;
|
|
2545
|
+
font-weight: 600;
|
|
2546
|
+
color: var(--text-primary);
|
|
2547
|
+
margin: 0;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
.shortcuts-close {
|
|
2551
|
+
background: none;
|
|
2552
|
+
border: none;
|
|
2553
|
+
color: var(--text-muted);
|
|
2554
|
+
font-size: 1.1rem;
|
|
2555
|
+
cursor: pointer;
|
|
2556
|
+
padding: 0.25rem;
|
|
2557
|
+
line-height: 1;
|
|
2558
|
+
transition: color 0.15s;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
.shortcuts-close:hover {
|
|
2562
|
+
color: var(--text-primary);
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
.shortcuts-grid {
|
|
2566
|
+
display: grid;
|
|
2567
|
+
grid-template-columns: 1fr 1fr;
|
|
2568
|
+
gap: 1.5rem;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
.shortcut-section h4 {
|
|
2572
|
+
font-size: 0.7rem;
|
|
2573
|
+
text-transform: uppercase;
|
|
2574
|
+
letter-spacing: 0.08em;
|
|
2575
|
+
color: var(--text-dim);
|
|
2576
|
+
font-weight: 600;
|
|
2577
|
+
margin: 0 0 0.6rem 0;
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
.shortcut-row {
|
|
2581
|
+
display: flex;
|
|
2582
|
+
align-items: center;
|
|
2583
|
+
gap: 0.4rem;
|
|
2584
|
+
padding: 0.3rem 0;
|
|
2585
|
+
font-size: 0.8rem;
|
|
2586
|
+
color: var(--text-secondary);
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
.shortcut-row span {
|
|
2590
|
+
flex: 1;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
.shortcut-row kbd {
|
|
2594
|
+
display: inline-flex;
|
|
2595
|
+
align-items: center;
|
|
2596
|
+
justify-content: center;
|
|
2597
|
+
min-width: 24px;
|
|
2598
|
+
height: 24px;
|
|
2599
|
+
padding: 0 6px;
|
|
2600
|
+
background: rgba(255, 255, 255, 0.06);
|
|
2601
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
2602
|
+
border-radius: 5px;
|
|
2603
|
+
font-family: var(--font-mono);
|
|
2604
|
+
font-size: 0.68rem;
|
|
2605
|
+
font-weight: 600;
|
|
2606
|
+
color: var(--text-primary);
|
|
2607
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
|
2608
|
+
line-height: 1;
|
|
2609
|
+
flex-shrink: 0;
|
|
2449
2610
|
}
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
/** @jsxImportSource melina/client */
|
|
2
1
|
/**
|
|
3
2
|
* bgrun Dashboard — Page Client Interactivity
|
|
4
3
|
*
|
|
5
4
|
* NOT a React component. A mount function that adds interactivity
|
|
6
|
-
* to the server-rendered HTML. JSX
|
|
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;
|
|
@@ -418,6 +413,7 @@ export default function mount(): () => void {
|
|
|
418
413
|
let isFirstLoad = true;
|
|
419
414
|
let allProcesses: ProcessData[] = [];
|
|
420
415
|
let searchQuery = '';
|
|
416
|
+
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
421
417
|
let collapsedGroups: Set<string> = new Set(JSON.parse(localStorage.getItem('bgr_collapsed_groups') || '[]'));
|
|
422
418
|
let drawerProcess: string | null = null;
|
|
423
419
|
let drawerTab: 'stdout' | 'stderr' = 'stdout';
|
|
@@ -433,6 +429,28 @@ export default function mount(): () => void {
|
|
|
433
429
|
let logLastSize = -1; // Detect no-change polls
|
|
434
430
|
let logNeedsFullRebuild = true; // Full DOM rebuild flag (on tab switch, search change)
|
|
435
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
|
+
|
|
436
454
|
// ─── Version Badge ───
|
|
437
455
|
const versionBadge = $('version-badge');
|
|
438
456
|
async function loadVersion() {
|
|
@@ -457,14 +475,18 @@ export default function mount(): () => void {
|
|
|
457
475
|
allProcesses = await res.json();
|
|
458
476
|
renderFilteredProcesses();
|
|
459
477
|
updateStats(allProcesses);
|
|
460
|
-
} catch {
|
|
461
|
-
|
|
478
|
+
} catch (err) {
|
|
479
|
+
console.error('[bgr-dashboard] loadProcesses error:', err);
|
|
462
480
|
} finally {
|
|
463
481
|
isFetching = false;
|
|
464
482
|
}
|
|
465
483
|
}
|
|
466
484
|
|
|
467
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
|
+
}
|
|
468
490
|
const filtered = searchQuery
|
|
469
491
|
? allProcesses.filter(p =>
|
|
470
492
|
p.name.toLowerCase().includes(searchQuery) ||
|
|
@@ -473,6 +495,17 @@ export default function mount(): () => void {
|
|
|
473
495
|
)
|
|
474
496
|
: allProcesses;
|
|
475
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
|
+
}
|
|
476
509
|
}
|
|
477
510
|
|
|
478
511
|
function updateStats(processes: ProcessData[]) {
|
|
@@ -526,6 +559,16 @@ export default function mount(): () => void {
|
|
|
526
559
|
}
|
|
527
560
|
}
|
|
528
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();
|
|
570
|
+
}
|
|
571
|
+
|
|
529
572
|
function renderProcesses(processes: ProcessData[]) {
|
|
530
573
|
const tbody = $('processes-table');
|
|
531
574
|
const cardsEl = $('mobile-cards');
|
|
@@ -553,62 +596,65 @@ export default function mount(): () => void {
|
|
|
553
596
|
groups[key].push(p);
|
|
554
597
|
});
|
|
555
598
|
|
|
556
|
-
const nodes: Node[] = [];
|
|
557
599
|
const sortedGroupKeys = Object.keys(groups).sort();
|
|
558
600
|
|
|
559
|
-
//
|
|
601
|
+
// Build DOM nodes for table rows
|
|
602
|
+
const rows: Node[] = [];
|
|
560
603
|
sortedGroupKeys.forEach(groupDir => {
|
|
561
604
|
const procs = groups[groupDir];
|
|
562
605
|
const running = procs.filter(p => p.running).length;
|
|
563
606
|
const collapsed = collapsedGroups.has(groupDir);
|
|
564
|
-
|
|
607
|
+
rows.push(<GroupHeader name={groupDir} running={running} total={procs.length} collapsed={collapsed} /> as unknown as Node);
|
|
565
608
|
if (!collapsed) {
|
|
566
609
|
procs.forEach(p => {
|
|
567
|
-
|
|
610
|
+
rows.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
|
|
568
611
|
});
|
|
569
612
|
}
|
|
570
613
|
});
|
|
571
614
|
|
|
572
|
-
tbody
|
|
615
|
+
// Replace tbody contents with new DOM nodes
|
|
616
|
+
tbody.replaceChildren(...rows);
|
|
573
617
|
|
|
574
618
|
// Add click handlers for group headers (toggle collapse)
|
|
575
619
|
tbody.querySelectorAll('.group-header').forEach(header => {
|
|
576
620
|
header.addEventListener('click', (e: Event) => {
|
|
577
|
-
// Don't collapse if clicking action buttons
|
|
578
621
|
if ((e.target as Element).closest('[data-action]')) return;
|
|
579
622
|
const groupName = (header as HTMLElement).dataset.groupName;
|
|
580
623
|
if (!groupName) return;
|
|
581
|
-
|
|
582
|
-
collapsedGroups.delete(groupName);
|
|
583
|
-
} else {
|
|
584
|
-
collapsedGroups.add(groupName);
|
|
585
|
-
}
|
|
586
|
-
localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
|
|
587
|
-
renderFilteredProcesses();
|
|
624
|
+
toggleGroup(groupName);
|
|
588
625
|
});
|
|
589
626
|
});
|
|
590
627
|
|
|
591
628
|
// Render mobile cards
|
|
592
629
|
if (cardsEl) {
|
|
593
|
-
|
|
594
|
-
cardsEl.replaceChildren(...cards);
|
|
630
|
+
cardsEl.replaceChildren(...processes.map(p => <ProcessCard p={p} /> as unknown as Node));
|
|
595
631
|
}
|
|
596
632
|
|
|
597
633
|
if (isFirstLoad) isFirstLoad = false;
|
|
598
634
|
|
|
599
|
-
//
|
|
635
|
+
// Restore selected row + keyboard focus row
|
|
600
636
|
if (drawerProcess) {
|
|
601
|
-
const
|
|
637
|
+
const finalTbody = $('processes-table') || tbody;
|
|
638
|
+
const row = finalTbody.querySelector(`tr[data-process-name="${drawerProcess}"]`);
|
|
602
639
|
if (row) row.classList.add('selected');
|
|
603
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
|
+
}
|
|
604
647
|
}
|
|
605
648
|
|
|
606
|
-
// ─── Search ───
|
|
649
|
+
// ─── Search (debounced 150ms) ───
|
|
607
650
|
|
|
608
651
|
const searchInput = $('search-input') as HTMLInputElement;
|
|
609
652
|
searchInput?.addEventListener('input', () => {
|
|
610
|
-
|
|
611
|
-
|
|
653
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
654
|
+
searchDebounce = setTimeout(() => {
|
|
655
|
+
searchQuery = searchInput.value.toLowerCase().trim();
|
|
656
|
+
renderFilteredProcesses();
|
|
657
|
+
}, 150);
|
|
612
658
|
});
|
|
613
659
|
|
|
614
660
|
/** Fetch with cache-bust to force fresh data after mutations */
|
|
@@ -925,6 +971,8 @@ export default function mount(): () => void {
|
|
|
925
971
|
logCurrentTab = '';
|
|
926
972
|
logLastSize = -1;
|
|
927
973
|
logNeedsFullRebuild = true;
|
|
974
|
+
logVirtualActive = false;
|
|
975
|
+
logFilteredIndices = [];
|
|
928
976
|
if (!skipRefresh) refreshDrawerLogs();
|
|
929
977
|
}
|
|
930
978
|
|
|
@@ -1124,8 +1172,70 @@ export default function mount(): () => void {
|
|
|
1124
1172
|
tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
|
|
1125
1173
|
}
|
|
1126
1174
|
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
+
}
|
|
1129
1239
|
|
|
1130
1240
|
function fullRebuildLogs(logsEl: HTMLElement) {
|
|
1131
1241
|
const search = logSearch.toLowerCase();
|
|
@@ -1133,48 +1243,78 @@ export default function mount(): () => void {
|
|
|
1133
1243
|
logsEl.innerHTML = '<em style="color: var(--text-muted)">No logs available</em>';
|
|
1134
1244
|
updateLogCount(0);
|
|
1135
1245
|
logNeedsFullRebuild = false;
|
|
1246
|
+
logVirtualActive = false;
|
|
1136
1247
|
return;
|
|
1137
1248
|
}
|
|
1138
1249
|
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
for (let i = 0; i < logLinesRaw.length; i++) {
|
|
1143
|
-
if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
|
|
1144
|
-
count++;
|
|
1145
|
-
const num = i + 1;
|
|
1146
|
-
chunks.push(`<div class="log-line" data-ln="${num}"><span class="log-line-num">${num}</span><span class="log-line-content">${logLinesHtml[i]}</span></div>`);
|
|
1147
|
-
}
|
|
1148
|
-
logsEl.innerHTML = chunks.join('');
|
|
1250
|
+
// Rebuild filtered indices
|
|
1251
|
+
rebuildFilteredIndices();
|
|
1252
|
+
const count = logFilteredIndices.length;
|
|
1149
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
|
+
}
|
|
1150
1268
|
logNeedsFullRebuild = false;
|
|
1269
|
+
|
|
1270
|
+
// Auto-calibrate line height from first rendered line
|
|
1271
|
+
if (!logLineHeightCalibrated) {
|
|
1272
|
+
requestAnimationFrame(() => calibrateLogLineHeight(logsEl));
|
|
1273
|
+
}
|
|
1151
1274
|
}
|
|
1152
1275
|
|
|
1153
1276
|
function appendNewLogLines(logsEl: HTMLElement, startIndex: number) {
|
|
1154
|
-
// Fast path: append only new lines to existing DOM
|
|
1155
1277
|
const search = logSearch.toLowerCase();
|
|
1156
|
-
|
|
1157
|
-
|
|
1278
|
+
|
|
1279
|
+
// Append to filtered indices
|
|
1158
1280
|
for (let i = startIndex; i < logLinesRaw.length; i++) {
|
|
1159
1281
|
if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
+
}
|
|
1173
1310
|
}
|
|
1174
1311
|
|
|
1175
1312
|
function updateLogCount(count: number) {
|
|
1176
1313
|
const countEl = $('log-line-count');
|
|
1177
|
-
if (countEl)
|
|
1314
|
+
if (countEl) {
|
|
1315
|
+
const suffix = logVirtualActive ? ' (virtual)' : '';
|
|
1316
|
+
countEl.textContent = `${count} line${count !== 1 ? 's' : ''}${suffix}`;
|
|
1317
|
+
}
|
|
1178
1318
|
}
|
|
1179
1319
|
|
|
1180
1320
|
async function refreshDrawerLogs() {
|
|
@@ -1287,6 +1427,10 @@ export default function mount(): () => void {
|
|
|
1287
1427
|
|
|
1288
1428
|
// Click log line → expand/collapse (word-wrap toggle)
|
|
1289
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
|
+
|
|
1290
1434
|
logsContainer?.addEventListener('click', (e: Event) => {
|
|
1291
1435
|
const line = (e.target as Element).closest('.log-line') as HTMLElement;
|
|
1292
1436
|
if (!line) return;
|
|
@@ -1470,6 +1614,9 @@ export default function mount(): () => void {
|
|
|
1470
1614
|
if (drawerProcess) refreshDrawerLogs();
|
|
1471
1615
|
});
|
|
1472
1616
|
|
|
1617
|
+
// ─── Shortcuts Button ───
|
|
1618
|
+
$('shortcuts-btn')?.addEventListener('click', toggleShortcutsOverlay);
|
|
1619
|
+
|
|
1473
1620
|
// ─── Guard All Button ───
|
|
1474
1621
|
$('guard-all-btn')?.addEventListener('click', async () => {
|
|
1475
1622
|
const guardAllBtn = $('guard-all-btn') as HTMLButtonElement;
|
|
@@ -1513,14 +1660,95 @@ export default function mount(): () => void {
|
|
|
1513
1660
|
// Group toggle removed — always-on directory grouping
|
|
1514
1661
|
|
|
1515
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
|
+
|
|
1516
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
|
+
|
|
1517
1738
|
// "/" to focus search (unless already in an input)
|
|
1518
|
-
if (e.key === '/' && !
|
|
1739
|
+
if (e.key === '/' && !inInput) {
|
|
1519
1740
|
e.preventDefault();
|
|
1520
1741
|
searchInput?.focus();
|
|
1521
1742
|
return;
|
|
1522
1743
|
}
|
|
1744
|
+
|
|
1745
|
+
// Escape: close overlays progressively
|
|
1523
1746
|
if (e.key === 'Escape') {
|
|
1747
|
+
const shortcutsOverlay = $('shortcuts-overlay');
|
|
1748
|
+
if (shortcutsOverlay?.classList.contains('active')) {
|
|
1749
|
+
shortcutsOverlay.classList.remove('active');
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1524
1752
|
if (contextMenuEl) {
|
|
1525
1753
|
closeContextMenu();
|
|
1526
1754
|
return;
|
|
@@ -1530,10 +1758,75 @@ export default function mount(): () => void {
|
|
|
1530
1758
|
} else {
|
|
1531
1759
|
closeModal();
|
|
1532
1760
|
}
|
|
1761
|
+
// Clear keyboard focus
|
|
1762
|
+
setProcessFocus(null);
|
|
1533
1763
|
// Blur search on escape
|
|
1534
1764
|
if (document.activeElement === searchInput) {
|
|
1535
1765
|
searchInput?.blur();
|
|
1536
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;
|
|
1537
1830
|
}
|
|
1538
1831
|
}
|
|
1539
1832
|
document.addEventListener('keydown', handleKeydown);
|
|
@@ -1542,10 +1835,17 @@ export default function mount(): () => void {
|
|
|
1542
1835
|
let eventSource: EventSource | null = null;
|
|
1543
1836
|
let logRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
1544
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();
|
|
1545
1843
|
|
|
1546
1844
|
function connectSSE() {
|
|
1845
|
+
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
1547
1846
|
eventSource = new EventSource('/api/events');
|
|
1548
1847
|
eventSource.onmessage = (event) => {
|
|
1848
|
+
sseRetryDelay = 2_000; // reset backoff on success
|
|
1549
1849
|
// Skip SSE updates briefly after mutations to avoid flicker
|
|
1550
1850
|
if (Date.now() < mutationUntil) return;
|
|
1551
1851
|
try {
|
|
@@ -1561,14 +1861,29 @@ export default function mount(): () => void {
|
|
|
1561
1861
|
} catch { /* invalid data, skip */ }
|
|
1562
1862
|
};
|
|
1563
1863
|
eventSource.onerror = () => {
|
|
1564
|
-
// SSE disconnected
|
|
1864
|
+
// SSE disconnected — exponential backoff reconnect
|
|
1565
1865
|
eventSource?.close();
|
|
1566
1866
|
eventSource = null;
|
|
1567
|
-
setTimeout(connectSSE,
|
|
1867
|
+
setTimeout(connectSSE, sseRetryDelay);
|
|
1868
|
+
sseRetryDelay = Math.min(sseRetryDelay * 2, SSE_MAX_RETRY);
|
|
1568
1869
|
};
|
|
1569
1870
|
}
|
|
1570
1871
|
connectSSE();
|
|
1571
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
|
+
|
|
1572
1887
|
// Log drawer still needs periodic refresh (not part of SSE)
|
|
1573
1888
|
logRefreshTimer = setInterval(() => {
|
|
1574
1889
|
if (drawerProcess) refreshDrawerLogs();
|
|
@@ -1584,9 +1899,13 @@ export default function mount(): () => void {
|
|
|
1584
1899
|
$('modal-create-btn')?.removeEventListener('click', createProcess);
|
|
1585
1900
|
$('refresh-btn')?.removeEventListener('click', loadProcesses);
|
|
1586
1901
|
document.removeEventListener('keydown', handleKeydown);
|
|
1902
|
+
document.removeEventListener('visibilitychange', handleVisibility);
|
|
1587
1903
|
closeContextMenu();
|
|
1588
1904
|
if (eventSource) eventSource.close();
|
|
1589
1905
|
if (logRefreshTimer) clearInterval(logRefreshTimer);
|
|
1590
1906
|
if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
|
|
1907
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
1908
|
+
if (logScrollRAF) cancelAnimationFrame(logScrollRAF);
|
|
1909
|
+
logsContainer?.removeEventListener('scroll', onLogScroll);
|
|
1591
1910
|
};
|
|
1592
1911
|
}
|
package/dashboard/app/page.tsx
CHANGED
|
@@ -53,6 +53,7 @@ export default function DashboardPage() {
|
|
|
53
53
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
54
54
|
</svg>
|
|
55
55
|
<input type="text" className="search-input" id="search-input" placeholder="Filter processes..." />
|
|
56
|
+
<span className="search-count" id="search-count" style={{ display: 'none' }}></span>
|
|
56
57
|
<span className="search-shortcut">/</span>
|
|
57
58
|
</div>
|
|
58
59
|
</div>
|
|
@@ -73,6 +74,9 @@ export default function DashboardPage() {
|
|
|
73
74
|
<span className="guard-sentinel-dot" id="guard-sentinel-dot" />
|
|
74
75
|
<span id="guard-sentinel-label">Guard: –</span>
|
|
75
76
|
</span>
|
|
77
|
+
<button className="btn btn-ghost btn-icon" id="shortcuts-btn" title="Keyboard Shortcuts (?)">
|
|
78
|
+
<span style={{ fontSize: '0.85rem', fontWeight: '700' }}>?</span>
|
|
79
|
+
</button>
|
|
76
80
|
<button className="btn btn-primary" id="new-process-btn">
|
|
77
81
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
78
82
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
@@ -221,6 +225,34 @@ export default function DashboardPage() {
|
|
|
221
225
|
</div>
|
|
222
226
|
</div>
|
|
223
227
|
</div>
|
|
228
|
+
|
|
229
|
+
{/* Keyboard Shortcuts Overlay */}
|
|
230
|
+
<div className="shortcuts-overlay" id="shortcuts-overlay">
|
|
231
|
+
<div className="shortcuts-panel">
|
|
232
|
+
<div className="shortcuts-header">
|
|
233
|
+
<h3>⌨️ Keyboard Shortcuts</h3>
|
|
234
|
+
<button className="shortcuts-close" id="shortcuts-close-btn">✕</button>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="shortcuts-grid">
|
|
237
|
+
<div className="shortcut-section">
|
|
238
|
+
<h4>Navigation</h4>
|
|
239
|
+
<div className="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Navigate processes</span></div>
|
|
240
|
+
<div className="shortcut-row"><kbd>Enter</kbd><span>Open process drawer</span></div>
|
|
241
|
+
<div className="shortcut-row"><kbd>/</kbd><span>Focus search</span></div>
|
|
242
|
+
<div className="shortcut-row"><kbd>Esc</kbd><span>Close panel / blur</span></div>
|
|
243
|
+
</div>
|
|
244
|
+
<div className="shortcut-section">
|
|
245
|
+
<h4>Actions</h4>
|
|
246
|
+
<div className="shortcut-row"><kbd>R</kbd><span>Restart process</span></div>
|
|
247
|
+
<div className="shortcut-row"><kbd>S</kbd><span>Stop process</span></div>
|
|
248
|
+
<div className="shortcut-row"><kbd>G</kbd><span>Toggle guard</span></div>
|
|
249
|
+
<div className="shortcut-row"><kbd>D</kbd><span>Delete process</span></div>
|
|
250
|
+
<div className="shortcut-row"><kbd>N</kbd><span>New process</span></div>
|
|
251
|
+
<div className="shortcut-row"><kbd>?</kbd><span>This help</span></div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
224
256
|
</div>
|
|
225
257
|
);
|
|
226
258
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bgrun",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "bgrun — A lightweight process manager for Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/api.ts",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"dist",
|
|
20
20
|
"src",
|
|
21
21
|
"dashboard/app",
|
|
22
|
+
"scripts",
|
|
22
23
|
"README.md",
|
|
23
24
|
"image.png",
|
|
24
25
|
"examples/bgr-startup.sh"
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
"chalk": "^5.4.1",
|
|
51
52
|
"dedent": "^1.5.3",
|
|
52
53
|
"measure-fn": "3.10.1",
|
|
53
|
-
"melina": "2.3.
|
|
54
|
+
"melina": "2.3.6",
|
|
54
55
|
"react": "^19.2.4",
|
|
55
56
|
"react-dom": "^19.2.4",
|
|
56
57
|
"sqlite-zod-orm": "3.26.1"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# bgr-startup.ps1 — Auto-start bgrun guard on Windows login
|
|
2
|
+
# Ensures all guarded processes (and the dashboard itself) start on boot.
|
|
3
|
+
#
|
|
4
|
+
# Installation:
|
|
5
|
+
# 1. Run this script once with -Install flag:
|
|
6
|
+
# powershell -ExecutionPolicy Bypass -File bgr-startup.ps1 -Install
|
|
7
|
+
#
|
|
8
|
+
# 2. Or manually create a Task Scheduler task:
|
|
9
|
+
# - Trigger: At log on
|
|
10
|
+
# - Action: powershell -WindowStyle Hidden -ExecutionPolicy Bypass -File "C:\Code\bgr\scripts\bgr-startup.ps1"
|
|
11
|
+
# - Run whether user is logged on or not: Yes
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# bgr-startup.ps1 # Start bgrun guard
|
|
15
|
+
# bgr-startup.ps1 -Install # Register Task Scheduler entry
|
|
16
|
+
|
|
17
|
+
param(
|
|
18
|
+
[switch]$Install
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
$BunPath = "$env:USERPROFILE\.bun\bin\bun.exe"
|
|
22
|
+
$BgrunPath = "C:\Code\bgr"
|
|
23
|
+
$LogPath = "$env:USERPROFILE\.bgr\startup.log"
|
|
24
|
+
|
|
25
|
+
function Write-Log {
|
|
26
|
+
param([string]$Message)
|
|
27
|
+
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
28
|
+
$line = "[$timestamp] $Message"
|
|
29
|
+
Write-Host $line
|
|
30
|
+
Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Ensure .bgr directory exists
|
|
34
|
+
$bgrDir = "$env:USERPROFILE\.bgr"
|
|
35
|
+
if (-not (Test-Path $bgrDir)) {
|
|
36
|
+
New-Item -ItemType Directory -Path $bgrDir -Force | Out-Null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if ($Install) {
|
|
40
|
+
Write-Log "Installing bgrun auto-start task..."
|
|
41
|
+
|
|
42
|
+
$scriptPath = $PSCommandPath
|
|
43
|
+
if (-not $scriptPath) {
|
|
44
|
+
$scriptPath = Join-Path $BgrunPath "scripts\bgr-startup.ps1"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Remove existing task if present
|
|
48
|
+
$existingTask = Get-ScheduledTask -TaskName "bgrun-guard" -ErrorAction SilentlyContinue
|
|
49
|
+
if ($existingTask) {
|
|
50
|
+
Unregister-ScheduledTask -TaskName "bgrun-guard" -Confirm:$false
|
|
51
|
+
Write-Log "Removed existing bgrun-guard task"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Create the scheduled task
|
|
55
|
+
$action = New-ScheduledTaskAction `
|
|
56
|
+
-Execute "powershell.exe" `
|
|
57
|
+
-Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$scriptPath`""
|
|
58
|
+
|
|
59
|
+
$trigger = New-ScheduledTaskTrigger -AtLogon
|
|
60
|
+
$settings = New-ScheduledTaskSettingsSet `
|
|
61
|
+
-AllowStartIfOnBatteries `
|
|
62
|
+
-DontStopIfGoingOnBatteries `
|
|
63
|
+
-StartWhenAvailable `
|
|
64
|
+
-RestartInterval (New-TimeSpan -Minutes 5) `
|
|
65
|
+
-RestartCount 3
|
|
66
|
+
|
|
67
|
+
Register-ScheduledTask `
|
|
68
|
+
-TaskName "bgrun-guard" `
|
|
69
|
+
-Description "bgrun process manager — auto-starts all guarded processes on login" `
|
|
70
|
+
-Action $action `
|
|
71
|
+
-Trigger $trigger `
|
|
72
|
+
-Settings $settings `
|
|
73
|
+
-RunLevel Highest `
|
|
74
|
+
-Force
|
|
75
|
+
|
|
76
|
+
Write-Log "✓ Task 'bgrun-guard' registered. Will start on next login."
|
|
77
|
+
Write-Log " Script: $scriptPath"
|
|
78
|
+
exit 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# ── Main: Start bgrun guard ─────────────────────────────────
|
|
82
|
+
Write-Log "bgrun startup initiated"
|
|
83
|
+
|
|
84
|
+
# Check bun exists
|
|
85
|
+
if (-not (Test-Path $BunPath)) {
|
|
86
|
+
Write-Log "ERROR: bun not found at $BunPath"
|
|
87
|
+
exit 1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Check bgrun repo exists
|
|
91
|
+
if (-not (Test-Path "$BgrunPath\src\guard.ts")) {
|
|
92
|
+
Write-Log "ERROR: bgrun not found at $BgrunPath"
|
|
93
|
+
exit 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Start the dashboard first (guard needs it)
|
|
97
|
+
Write-Log "Starting bgrun dashboard..."
|
|
98
|
+
$dashboardProc = Start-Process -FilePath $BunPath `
|
|
99
|
+
-ArgumentList "run", "$BgrunPath\src\index.ts", "--dashboard", "--port", "3000" `
|
|
100
|
+
-WindowStyle Hidden `
|
|
101
|
+
-PassThru `
|
|
102
|
+
-WorkingDirectory $BgrunPath
|
|
103
|
+
|
|
104
|
+
Write-Log "Dashboard PID: $($dashboardProc.Id)"
|
|
105
|
+
|
|
106
|
+
# Wait for dashboard to be ready
|
|
107
|
+
Start-Sleep -Seconds 5
|
|
108
|
+
|
|
109
|
+
# Start the guard (watches dashboard + all guarded processes)
|
|
110
|
+
Write-Log "Starting bgrun guard..."
|
|
111
|
+
$guardProc = Start-Process -FilePath $BunPath `
|
|
112
|
+
-ArgumentList "run", "$BgrunPath\src\index.ts", "--guard" `
|
|
113
|
+
-WindowStyle Hidden `
|
|
114
|
+
-PassThru `
|
|
115
|
+
-WorkingDirectory $BgrunPath
|
|
116
|
+
|
|
117
|
+
Write-Log "Guard PID: $($guardProc.Id)"
|
|
118
|
+
Write-Log "✓ bgrun startup complete. Dashboard: $($dashboardProc.Id), Guard: $($guardProc.Id)"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bgrun core utility tests
|
|
3
|
+
*
|
|
4
|
+
* Tests pure logic functions: env parsing, config flattening,
|
|
5
|
+
* string truncation, and runtime calculation.
|
|
6
|
+
*
|
|
7
|
+
* Run: bun test src/bgrun.test.ts
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test'
|
|
10
|
+
import { parseEnvString, calculateRuntime } from './utils'
|
|
11
|
+
import { stripAnsi, truncateString, truncatePath } from './table'
|
|
12
|
+
|
|
13
|
+
// ─── parseEnvString ─────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('parseEnvString', () => {
|
|
16
|
+
test('parses comma-separated key=value pairs', () => {
|
|
17
|
+
const result = parseEnvString('PORT=3000,HOST=localhost,DEBUG=true')
|
|
18
|
+
expect(result).toEqual({
|
|
19
|
+
PORT: '3000',
|
|
20
|
+
HOST: 'localhost',
|
|
21
|
+
DEBUG: 'true',
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('handles single pair', () => {
|
|
26
|
+
expect(parseEnvString('KEY=value')).toEqual({ KEY: 'value' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('handles empty string', () => {
|
|
30
|
+
expect(parseEnvString('')).toEqual({})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('ignores malformed pairs (no =)', () => {
|
|
34
|
+
const result = parseEnvString('GOOD=yes,BAD,ALSO_GOOD=ok')
|
|
35
|
+
expect(result.GOOD).toBe('yes')
|
|
36
|
+
expect(result.ALSO_GOOD).toBe('ok')
|
|
37
|
+
expect(result.BAD).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// ─── calculateRuntime ───────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('calculateRuntime', () => {
|
|
44
|
+
test('returns 0 minutes for recent start', () => {
|
|
45
|
+
const now = new Date().toISOString()
|
|
46
|
+
expect(calculateRuntime(now)).toBe('0 minutes')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('returns correct minutes', () => {
|
|
50
|
+
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString()
|
|
51
|
+
expect(calculateRuntime(fiveMinAgo)).toBe('5 minutes')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('returns correct for 1 hour', () => {
|
|
55
|
+
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
|
|
56
|
+
expect(calculateRuntime(oneHourAgo)).toBe('60 minutes')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ─── stripAnsi ──────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
describe('stripAnsi', () => {
|
|
63
|
+
test('strips color codes', () => {
|
|
64
|
+
const colored = '\u001b[31mred text\u001b[0m'
|
|
65
|
+
expect(stripAnsi(colored)).toBe('red text')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('passes through plain text', () => {
|
|
69
|
+
expect(stripAnsi('hello world')).toBe('hello world')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('handles empty string', () => {
|
|
73
|
+
expect(stripAnsi('')).toBe('')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// ─── truncateString ─────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe('truncateString', () => {
|
|
80
|
+
test('returns string unchanged if within limit', () => {
|
|
81
|
+
expect(truncateString('hello', 10)).toBe('hello')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('truncates with ellipsis', () => {
|
|
85
|
+
const result = truncateString('a very long string that exceeds limit', 15)
|
|
86
|
+
expect(result.length).toBeLessThanOrEqual(15)
|
|
87
|
+
expect(result).toContain('…')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('handles maxLength smaller than ellipsis', () => {
|
|
91
|
+
const result = truncateString('hello world', 2)
|
|
92
|
+
expect(result.length).toBeLessThanOrEqual(2)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ─── truncatePath ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('truncatePath', () => {
|
|
99
|
+
test('returns path unchanged if within limit', () => {
|
|
100
|
+
expect(truncatePath('/home/user', 50)).toBe('/home/user')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('truncates middle of long path', () => {
|
|
104
|
+
const longPath = '/home/user/projects/very/deeply/nested/directory/structure'
|
|
105
|
+
const result = truncatePath(longPath, 30)
|
|
106
|
+
expect(result.length).toBeLessThanOrEqual(30)
|
|
107
|
+
expect(result).toContain('…')
|
|
108
|
+
})
|
|
109
|
+
})
|
package/src/table.ts
CHANGED
|
@@ -34,12 +34,12 @@ export function getTerminalWidth(): number {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// Strip ANSI color codes for accurate length calculation
|
|
37
|
-
function stripAnsi(str: string): string {
|
|
37
|
+
export function stripAnsi(str: string): string {
|
|
38
38
|
return str.replace(/\u001b\[[0-9;]*m/g, "");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// Default truncator: trims the end of a string
|
|
42
|
-
function truncateString(str: string, maxLength: number): string {
|
|
42
|
+
export function truncateString(str: string, maxLength: number): string {
|
|
43
43
|
const stripped = stripAnsi(str);
|
|
44
44
|
if (stripped.length <= maxLength) return str;
|
|
45
45
|
const ellipsis = "…";
|
|
@@ -52,7 +52,7 @@ function truncateString(str: string, maxLength: number): string {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Path truncator: trims the middle of a string
|
|
55
|
-
function truncatePath(str: string, maxLength: number): string {
|
|
55
|
+
export function truncatePath(str: string, maxLength: number): string {
|
|
56
56
|
const stripped = stripAnsi(str);
|
|
57
57
|
if (stripped.length <= maxLength) return str;
|
|
58
58
|
const ellipsis = "…";
|