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