bgrun 3.12.0 → 3.12.2
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/dashboard/app/api/check-port/route.ts +35 -0
- package/dashboard/app/api/dependencies/route.ts +40 -0
- package/dashboard/app/api/deploy/[name]/route.ts +6 -41
- package/dashboard/app/api/deploy-all/route.ts +25 -0
- 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/next-port/route.ts +32 -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 +11 -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 +1565 -5
- package/dashboard/app/page.client.tsx +1907 -2
- package/dashboard/app/page.tsx +292 -5
- package/dist/index.js +787 -194
- package/package.json +2 -2
- package/scripts/bgr-startup.ps1 +3 -3
- package/scripts/bgrun-startup.ps1 +91 -0
- package/src/bgrun.test.ts +171 -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 +257 -0
- package/src/deploy.ts +163 -0
- package/src/guard.ts +51 -0
- package/src/index.ts +92 -14
- package/src/index_copy.ts +614 -0
- package/src/logger.ts +12 -2
- package/src/platform.ts +101 -56
- package/src/server.ts +87 -3
- package/src/utils.ts +2 -2
|
@@ -288,6 +288,7 @@ function ProcessCard({ p }: { p: ProcessData }) {
|
|
|
288
288
|
<div className="card-header">
|
|
289
289
|
<div className="process-name">
|
|
290
290
|
<span>{p.name}</span>
|
|
291
|
+
{p.group && <span className="group-badge" title={`Group: ${p.group}`}>{p.group}</span>}
|
|
291
292
|
{guarded && <span className="guard-badge" title="Auto-restart enabled">🛡️</span>}
|
|
292
293
|
</div>
|
|
293
294
|
<span className={`status-badge ${p.running ? 'running' : 'stopped'}`}>
|
|
@@ -408,11 +409,34 @@ function showToast(message: string, type: 'success' | 'error' | 'info' = 'info')
|
|
|
408
409
|
|
|
409
410
|
export default function mount(): () => void {
|
|
410
411
|
const $ = (id: string) => document.getElementById(id);
|
|
412
|
+
|
|
413
|
+
// ─── Theme Toggle ───
|
|
414
|
+
let currentTheme = localStorage.getItem('bgr_theme') || 'dark';
|
|
415
|
+
function applyTheme(theme: string) {
|
|
416
|
+
currentTheme = theme;
|
|
417
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
418
|
+
localStorage.setItem('bgr_theme', theme);
|
|
419
|
+
const icon = $('theme-toggle-icon');
|
|
420
|
+
if (icon) icon.textContent = theme === 'light' ? '☀️' : '🌙';
|
|
421
|
+
}
|
|
422
|
+
applyTheme(currentTheme);
|
|
423
|
+
$('theme-toggle-btn')?.addEventListener('click', () => {
|
|
424
|
+
applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
|
|
425
|
+
});
|
|
426
|
+
|
|
411
427
|
let selectedProcess: string | null = null;
|
|
412
428
|
let isFetching = false;
|
|
413
429
|
let isFirstLoad = true;
|
|
414
430
|
let allProcesses: ProcessData[] = [];
|
|
415
431
|
let searchQuery = '';
|
|
432
|
+
let groupQuery = '';
|
|
433
|
+
let statusFilter: 'all' | 'running' | 'stopped' | 'guarded' = (() => {
|
|
434
|
+
const saved = localStorage.getItem('bgr_status_filter');
|
|
435
|
+
return (saved === 'running' || saved === 'stopped' || saved === 'guarded') ? saved : 'all';
|
|
436
|
+
})();
|
|
437
|
+
const deployPresetKey = 'bgr_deploy_concurrency_presets';
|
|
438
|
+
const deployPresets = JSON.parse(localStorage.getItem(deployPresetKey) || '{}') as Record<string, number>;
|
|
439
|
+
let deployConcurrency = Math.max(1, Math.min(4, parseInt(localStorage.getItem('bgr_deploy_concurrency') || '1') || 1));
|
|
416
440
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
417
441
|
let collapsedGroups: Set<string> = new Set(JSON.parse(localStorage.getItem('bgr_collapsed_groups') || '[]'));
|
|
418
442
|
let drawerProcess: string | null = null;
|
|
@@ -421,6 +445,30 @@ export default function mount(): () => void {
|
|
|
421
445
|
let mutationUntil = 0; // Timestamp: ignore SSE updates until this time (after mutations)
|
|
422
446
|
let configSubtab = 'toml'; // 'toml' | 'env'
|
|
423
447
|
let logAutoScroll = localStorage.getItem('bgr_autoscroll') === 'true'; // OFF by default
|
|
448
|
+
let historyDensity = localStorage.getItem('bgr_history_density') === 'compact' ? 'compact' : 'cozy';
|
|
449
|
+
let historyShortcutsEnabled = localStorage.getItem('bgr_history_shortcuts') !== 'false';
|
|
450
|
+
let historyDetailsDefault = localStorage.getItem('bgr_history_details_default') === 'expanded' ? 'expanded' : 'collapsed';
|
|
451
|
+
let historyHintsVisible = localStorage.getItem('bgr_history_hints_visible') !== 'false';
|
|
452
|
+
let historyHintDensity = localStorage.getItem('bgr_history_hint_density') === 'compact' ? 'compact' : 'full';
|
|
453
|
+
let historyAutoOpen = localStorage.getItem('bgr_history_auto_open') === 'true';
|
|
454
|
+
let historyFocusScope = localStorage.getItem('bgr_history_focus_scope') === 'inspect' ? 'inspect' : 'sync';
|
|
455
|
+
let historyHintGroups = (() => {
|
|
456
|
+
try {
|
|
457
|
+
const raw = JSON.parse(localStorage.getItem('bgr_history_hint_groups') || 'null');
|
|
458
|
+
return {
|
|
459
|
+
nav: raw?.nav !== false,
|
|
460
|
+
open: raw?.open !== false,
|
|
461
|
+
filter: raw?.filter !== false,
|
|
462
|
+
details: raw?.details !== false,
|
|
463
|
+
close: raw?.close !== false,
|
|
464
|
+
};
|
|
465
|
+
} catch {
|
|
466
|
+
return { nav: true, open: true, filter: true, details: true, close: true };
|
|
467
|
+
}
|
|
468
|
+
})();
|
|
469
|
+
let focusedHistoryIndex = 0;
|
|
470
|
+
let focusedHistoryKey: string | null = null;
|
|
471
|
+
let historyDetailState = new Map<string, boolean>();
|
|
424
472
|
let logSearch = '';
|
|
425
473
|
let logLinesRaw: string[] = []; // Raw text (for search filtering)
|
|
426
474
|
let logLinesHtml: string[] = []; // Pre-converted HTML (cached ansiToHtml)
|
|
@@ -465,6 +513,117 @@ export default function mount(): () => void {
|
|
|
465
513
|
}
|
|
466
514
|
loadVersion();
|
|
467
515
|
|
|
516
|
+
const deployConcurrencySelect = $('deploy-concurrency-select') as HTMLSelectElement | null;
|
|
517
|
+
const deployPresetResetBtn = $('deploy-preset-reset-btn') as HTMLButtonElement | null;
|
|
518
|
+
const deployPresetSourceEl = $('deploy-preset-source');
|
|
519
|
+
|
|
520
|
+
function getDeployPresetKey(group: string): string {
|
|
521
|
+
return group ? `group:${group}` : '__all__';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function applyDeployConcurrencyPreset(group: string) {
|
|
525
|
+
const preset = deployPresets[getDeployPresetKey(group)];
|
|
526
|
+
const next = Math.max(1, Math.min(4, preset || parseInt(localStorage.getItem('bgr_deploy_concurrency') || '1') || 1));
|
|
527
|
+
deployConcurrency = next;
|
|
528
|
+
localStorage.setItem('bgr_deploy_concurrency', String(deployConcurrency));
|
|
529
|
+
if (deployConcurrencySelect) deployConcurrencySelect.value = String(deployConcurrency);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function saveDeployConcurrencyPreset(group: string, concurrency: number) {
|
|
533
|
+
deployPresets[getDeployPresetKey(group)] = concurrency;
|
|
534
|
+
localStorage.setItem(deployPresetKey, JSON.stringify(deployPresets));
|
|
535
|
+
localStorage.setItem('bgr_deploy_concurrency', String(concurrency));
|
|
536
|
+
updateDeployPresetResetButton();
|
|
537
|
+
updateDeployPresetScopes();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function resetDeployConcurrencyPreset(group: string) {
|
|
541
|
+
delete deployPresets[getDeployPresetKey(group)];
|
|
542
|
+
localStorage.setItem(deployPresetKey, JSON.stringify(deployPresets));
|
|
543
|
+
applyDeployConcurrencyPreset(group);
|
|
544
|
+
updateDeployPresetResetButton();
|
|
545
|
+
updateDeployPresetScopes();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function updateDeployPresetIndicator() {
|
|
549
|
+
const hasPreset = Object.prototype.hasOwnProperty.call(deployPresets, getDeployPresetKey(groupQuery));
|
|
550
|
+
if (deployPresetSourceEl) {
|
|
551
|
+
deployPresetSourceEl.textContent = hasPreset ? 'preset' : 'default';
|
|
552
|
+
deployPresetSourceEl.classList.toggle('is-preset', hasPreset);
|
|
553
|
+
deployPresetSourceEl.title = hasPreset
|
|
554
|
+
? `Using saved deploy preset for ${groupQuery || 'All Groups'}`
|
|
555
|
+
: `Using default deploy concurrency for ${groupQuery || 'All Groups'}`;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function updateDeployPresetResetButton() {
|
|
560
|
+
if (!deployPresetResetBtn) return;
|
|
561
|
+
const hasPreset = Object.prototype.hasOwnProperty.call(deployPresets, getDeployPresetKey(groupQuery));
|
|
562
|
+
deployPresetResetBtn.disabled = !hasPreset;
|
|
563
|
+
deployPresetResetBtn.style.opacity = hasPreset ? '' : '0.45';
|
|
564
|
+
deployPresetResetBtn.title = hasPreset
|
|
565
|
+
? `Reset saved deploy preset for ${groupQuery || 'All Groups'}`
|
|
566
|
+
: `No saved deploy preset for ${groupQuery || 'All Groups'}`;
|
|
567
|
+
updateDeployPresetIndicator();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (deployConcurrencySelect) {
|
|
571
|
+
deployConcurrencySelect.value = String(deployConcurrency);
|
|
572
|
+
deployConcurrencySelect.addEventListener('change', () => {
|
|
573
|
+
deployConcurrency = Math.max(1, Math.min(4, parseInt(deployConcurrencySelect.value) || 1));
|
|
574
|
+
saveDeployConcurrencyPreset(groupQuery, deployConcurrency);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
deployPresetResetBtn?.addEventListener('click', () => {
|
|
579
|
+
resetDeployConcurrencyPreset(groupQuery);
|
|
580
|
+
showToast(`Reset deploy preset for ${groupQuery || 'All Groups'}`, 'success');
|
|
581
|
+
updateDeployAllButton();
|
|
582
|
+
});
|
|
583
|
+
updateDeployPresetResetButton();
|
|
584
|
+
|
|
585
|
+
// ─── Guard Activity Feed ───
|
|
586
|
+
interface GuardEvent {
|
|
587
|
+
time: number;
|
|
588
|
+
name: string;
|
|
589
|
+
action: string;
|
|
590
|
+
success: boolean;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function loadGuardEvents() {
|
|
594
|
+
const listEl = $('guard-activity-list');
|
|
595
|
+
const emptyEl = $('guard-activity-empty');
|
|
596
|
+
if (!listEl) return;
|
|
597
|
+
try {
|
|
598
|
+
const res = await fetch('/api/guard-events');
|
|
599
|
+
const events: GuardEvent[] = await res.json();
|
|
600
|
+
if (events.length === 0) {
|
|
601
|
+
if (emptyEl) emptyEl.style.display = '';
|
|
602
|
+
listEl.innerHTML = '';
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (emptyEl) emptyEl.style.display = 'none';
|
|
606
|
+
listEl.replaceChildren(...events.slice(0, 10).map(ev => {
|
|
607
|
+
const date = new Date(ev.time);
|
|
608
|
+
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
609
|
+
const icon = ev.success ? '↻' : '✕';
|
|
610
|
+
const actionText = ev.action === 'restart' ? 'restarted' : ev.action;
|
|
611
|
+
return (
|
|
612
|
+
<div className={`guard-event ${ev.success ? 'success' : 'failed'}`}>
|
|
613
|
+
<span className="guard-event-time">{timeStr}</span>
|
|
614
|
+
<span className="guard-event-icon">{icon}</span>
|
|
615
|
+
<span className="guard-event-name">{ev.name}</span>
|
|
616
|
+
<span className="guard-event-action">{actionText}</span>
|
|
617
|
+
</div>
|
|
618
|
+
) as unknown as Node;
|
|
619
|
+
}));
|
|
620
|
+
} catch {
|
|
621
|
+
if (emptyEl) emptyEl.style.display = '';
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
loadGuardEvents();
|
|
625
|
+
setInterval(loadGuardEvents, 10000); // Refresh every 10s
|
|
626
|
+
|
|
468
627
|
// ─── Load & Render Processes ───
|
|
469
628
|
|
|
470
629
|
async function loadProcesses() {
|
|
@@ -473,6 +632,7 @@ export default function mount(): () => void {
|
|
|
473
632
|
try {
|
|
474
633
|
const res = await fetch('/api/processes');
|
|
475
634
|
allProcesses = await res.json();
|
|
635
|
+
updateGroupFilter();
|
|
476
636
|
renderFilteredProcesses();
|
|
477
637
|
updateStats(allProcesses);
|
|
478
638
|
} catch (err) {
|
|
@@ -482,19 +642,89 @@ export default function mount(): () => void {
|
|
|
482
642
|
}
|
|
483
643
|
}
|
|
484
644
|
|
|
645
|
+
function updateDeployPresetScopes() {
|
|
646
|
+
const scopesEl = $('deploy-preset-scopes');
|
|
647
|
+
if (!scopesEl) return;
|
|
648
|
+
|
|
649
|
+
const allGroups = new Set(allProcesses.map(p => p.group).filter(Boolean) as string[]);
|
|
650
|
+
const presetKeys = Object.keys(deployPresets);
|
|
651
|
+
const visibleScopes = presetKeys
|
|
652
|
+
.map(key => key === '__all__' ? '' : key.replace(/^group:/, ''))
|
|
653
|
+
.filter(scope => scope === '' || allGroups.has(scope));
|
|
654
|
+
|
|
655
|
+
if (visibleScopes.length === 0) {
|
|
656
|
+
scopesEl.innerHTML = '';
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
scopesEl.replaceChildren(
|
|
661
|
+
...visibleScopes.map(scope => (
|
|
662
|
+
<button
|
|
663
|
+
className={`deploy-preset-scope ${scope === groupQuery ? 'active' : ''}`}
|
|
664
|
+
data-action="switch-preset-scope"
|
|
665
|
+
data-scope={scope}
|
|
666
|
+
title={scope ? `Switch to group ${scope}` : 'Switch to All Groups'}
|
|
667
|
+
>
|
|
668
|
+
{scope || 'All'}
|
|
669
|
+
</button>
|
|
670
|
+
) as unknown as Node)
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function updateGroupFilter() {
|
|
675
|
+
const groupFilter = $('group-filter') as HTMLSelectElement;
|
|
676
|
+
if (!groupFilter) return;
|
|
677
|
+
const groups = new Set<string>();
|
|
678
|
+
for (const p of allProcesses) {
|
|
679
|
+
if (p.group) groups.add(p.group);
|
|
680
|
+
}
|
|
681
|
+
const currentValue = groupFilter.value;
|
|
682
|
+
groupFilter.replaceChildren(
|
|
683
|
+
<option value="">All Groups</option> as unknown as Node,
|
|
684
|
+
...Array.from(groups).sort().map(g => <option value={g}>{g}</option> as unknown as Node)
|
|
685
|
+
);
|
|
686
|
+
// Preserve selection if still valid
|
|
687
|
+
if (currentValue && groups.has(currentValue)) {
|
|
688
|
+
groupFilter.value = currentValue;
|
|
689
|
+
} else if (currentValue && !groups.has(currentValue)) {
|
|
690
|
+
groupFilter.value = '';
|
|
691
|
+
groupQuery = '';
|
|
692
|
+
}
|
|
693
|
+
applyDeployConcurrencyPreset(groupFilter.value || '');
|
|
694
|
+
updateDeployPresetScopes();
|
|
695
|
+
}
|
|
696
|
+
|
|
485
697
|
function renderFilteredProcesses() {
|
|
486
698
|
// Always sync searchQuery from DOM to prevent desync
|
|
487
699
|
if (searchInput && searchInput.value.toLowerCase().trim() !== searchQuery) {
|
|
488
700
|
searchQuery = searchInput.value.toLowerCase().trim();
|
|
489
701
|
}
|
|
490
|
-
|
|
702
|
+
// Sync groupQuery from dropdown
|
|
703
|
+
const groupFilter = $('group-filter') as HTMLSelectElement;
|
|
704
|
+
if (groupFilter && groupFilter.value !== groupQuery) {
|
|
705
|
+
groupQuery = groupFilter.value;
|
|
706
|
+
}
|
|
707
|
+
let filtered = searchQuery
|
|
491
708
|
? allProcesses.filter(p =>
|
|
492
709
|
p.name.toLowerCase().includes(searchQuery) ||
|
|
493
710
|
p.command.toLowerCase().includes(searchQuery) ||
|
|
494
711
|
(p.port && String(p.port).includes(searchQuery))
|
|
495
712
|
)
|
|
496
713
|
: allProcesses;
|
|
714
|
+
// Apply group filter
|
|
715
|
+
if (groupQuery) {
|
|
716
|
+
filtered = filtered.filter(p => p.group === groupQuery);
|
|
717
|
+
}
|
|
718
|
+
// Apply status filter from stat card clicks
|
|
719
|
+
if (statusFilter === 'running') {
|
|
720
|
+
filtered = filtered.filter(p => p.running);
|
|
721
|
+
} else if (statusFilter === 'stopped') {
|
|
722
|
+
filtered = filtered.filter(p => !p.running);
|
|
723
|
+
} else if (statusFilter === 'guarded') {
|
|
724
|
+
filtered = filtered.filter(p => isGuarded(p));
|
|
725
|
+
}
|
|
497
726
|
renderProcesses(filtered);
|
|
727
|
+
updateDeployAllButton();
|
|
498
728
|
|
|
499
729
|
// Update search result count badge
|
|
500
730
|
const badge = $('search-count');
|
|
@@ -508,6 +738,30 @@ export default function mount(): () => void {
|
|
|
508
738
|
}
|
|
509
739
|
}
|
|
510
740
|
|
|
741
|
+
function updateDeployAllButton() {
|
|
742
|
+
const btn = $('deploy-all-btn') as HTMLButtonElement;
|
|
743
|
+
const label = $('deploy-all-label');
|
|
744
|
+
if (!btn || !label) return;
|
|
745
|
+
|
|
746
|
+
const targetCount = allProcesses.filter(p => {
|
|
747
|
+
if (p.name === 'bgr-dashboard' || p.name === 'bgr-guard') return false;
|
|
748
|
+
if (groupQuery && p.group !== groupQuery) return false;
|
|
749
|
+
return true;
|
|
750
|
+
}).length;
|
|
751
|
+
|
|
752
|
+
if (groupQuery) {
|
|
753
|
+
label.textContent = `Deploy Group (${targetCount})`;
|
|
754
|
+
btn.title = `Git pull + restart deployable processes in group "${groupQuery}" with preset ${deployConcurrency}×`;
|
|
755
|
+
} else {
|
|
756
|
+
label.textContent = `Deploy All (${targetCount})`;
|
|
757
|
+
btn.title = `Git pull + restart all deployable processes with preset ${deployConcurrency}×`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
btn.disabled = targetCount === 0;
|
|
761
|
+
btn.style.opacity = targetCount === 0 ? '0.5' : '';
|
|
762
|
+
updateDeployPresetResetButton();
|
|
763
|
+
}
|
|
764
|
+
|
|
511
765
|
function updateStats(processes: ProcessData[]) {
|
|
512
766
|
const total = processes.length;
|
|
513
767
|
const running = processes.filter(p => p.running).length;
|
|
@@ -530,6 +784,18 @@ export default function mount(): () => void {
|
|
|
530
784
|
const totalRestarts = processes.reduce((sum, p) => sum + (p.guardRestarts || 0), 0);
|
|
531
785
|
if (rrc) rrc.textContent = String(totalRestarts);
|
|
532
786
|
|
|
787
|
+
const uptimeLongest = $('uptime-longest');
|
|
788
|
+
const uptimeTotal = $('uptime-total');
|
|
789
|
+
const runningProcesses = processes.filter(p => p.running && p.runtime > 0);
|
|
790
|
+
if (uptimeLongest) {
|
|
791
|
+
const longest = runningProcesses.reduce((max, p) => Math.max(max, p.runtime), 0);
|
|
792
|
+
uptimeLongest.textContent = longest > 0 ? formatRuntime(`${longest} minutes`) : '–';
|
|
793
|
+
}
|
|
794
|
+
if (uptimeTotal) {
|
|
795
|
+
const totalMinutes = runningProcesses.reduce((sum, p) => sum + p.runtime, 0);
|
|
796
|
+
uptimeTotal.textContent = totalMinutes > 0 ? formatRuntime(`${totalMinutes} minutes`) : '–';
|
|
797
|
+
}
|
|
798
|
+
|
|
533
799
|
// Update Guard All button state
|
|
534
800
|
const guardAllBtn = $('guard-all-btn');
|
|
535
801
|
const guardAllLabel = $('guard-all-label');
|
|
@@ -657,6 +923,72 @@ export default function mount(): () => void {
|
|
|
657
923
|
}, 150);
|
|
658
924
|
});
|
|
659
925
|
|
|
926
|
+
// ─── Group Filter ───
|
|
927
|
+
|
|
928
|
+
const groupFilter = $('group-filter') as HTMLSelectElement;
|
|
929
|
+
groupFilter?.addEventListener('change', () => {
|
|
930
|
+
groupQuery = groupFilter.value;
|
|
931
|
+
applyDeployConcurrencyPreset(groupQuery);
|
|
932
|
+
updateDeployPresetResetButton();
|
|
933
|
+
renderFilteredProcesses();
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
function updateStatFilterUI() {
|
|
937
|
+
document.querySelectorAll('[data-stat-filter]').forEach(el => {
|
|
938
|
+
el.classList.toggle('stat-active', (el as HTMLElement).dataset.statFilter === statusFilter);
|
|
939
|
+
});
|
|
940
|
+
const badge = $('stat-filter-badge');
|
|
941
|
+
const badgeLabel = $('stat-filter-badge-label');
|
|
942
|
+
if (badge && badgeLabel) {
|
|
943
|
+
if (statusFilter !== 'all') {
|
|
944
|
+
const labels: Record<string, string> = { running: 'Running', stopped: 'Stopped', guarded: 'Guarded' };
|
|
945
|
+
badgeLabel.textContent = labels[statusFilter] || statusFilter;
|
|
946
|
+
badge.style.display = '';
|
|
947
|
+
} else {
|
|
948
|
+
badge.style.display = 'none';
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function setStatusFilter(filter: typeof statusFilter) {
|
|
954
|
+
statusFilter = filter;
|
|
955
|
+
localStorage.setItem('bgr_status_filter', filter);
|
|
956
|
+
updateStatFilterUI();
|
|
957
|
+
renderFilteredProcesses();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
updateStatFilterUI();
|
|
961
|
+
|
|
962
|
+
$('stat-filter-badge-clear')?.addEventListener('click', () => {
|
|
963
|
+
setStatusFilter('all');
|
|
964
|
+
showToast('Status filter cleared', 'success');
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
$('stats-grid')?.addEventListener('click', (e) => {
|
|
968
|
+
const card = (e.target as Element).closest('[data-stat-filter]') as HTMLElement | null;
|
|
969
|
+
if (!card) return;
|
|
970
|
+
const filter = card.dataset.statFilter as typeof statusFilter;
|
|
971
|
+
setStatusFilter(statusFilter === filter ? 'all' : filter);
|
|
972
|
+
const labels: Record<string, string> = { all: 'all processes', running: 'running', stopped: 'stopped', guarded: 'guarded' };
|
|
973
|
+
showToast(`Showing ${labels[statusFilter] || statusFilter}`, 'info');
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
$('deploy-preset-scopes')?.addEventListener('click', (e) => {
|
|
977
|
+
const btn = (e.target as Element).closest('[data-action="switch-preset-scope"]') as HTMLElement | null;
|
|
978
|
+
const scope = btn?.dataset.scope;
|
|
979
|
+
if (scope === undefined) return;
|
|
980
|
+
|
|
981
|
+
const groupFilter = $('group-filter') as HTMLSelectElement | null;
|
|
982
|
+
if (groupFilter) {
|
|
983
|
+
groupFilter.value = scope;
|
|
984
|
+
}
|
|
985
|
+
groupQuery = scope;
|
|
986
|
+
applyDeployConcurrencyPreset(groupQuery);
|
|
987
|
+
updateDeployPresetResetButton();
|
|
988
|
+
renderFilteredProcesses();
|
|
989
|
+
showToast(`Switched to ${scope || 'All Groups'} preset`, 'info');
|
|
990
|
+
});
|
|
991
|
+
|
|
660
992
|
/** Fetch with cache-bust to force fresh data after mutations */
|
|
661
993
|
async function loadProcessesFresh() {
|
|
662
994
|
isFetching = true;
|
|
@@ -1561,26 +1893,134 @@ export default function mount(): () => void {
|
|
|
1561
1893
|
const nameInput = $('process-name-input') as HTMLInputElement;
|
|
1562
1894
|
const cmdInput = $('process-command-input') as HTMLInputElement;
|
|
1563
1895
|
const dirInput = $('process-directory-input') as HTMLInputElement;
|
|
1896
|
+
const portInput = $('process-port-input') as HTMLInputElement;
|
|
1564
1897
|
if (nameInput) nameInput.value = '';
|
|
1565
1898
|
if (cmdInput) cmdInput.value = '';
|
|
1566
1899
|
if (dirInput) dirInput.value = '';
|
|
1900
|
+
if (portInput) portInput.value = '';
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
let portCheckDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
1904
|
+
|
|
1905
|
+
async function checkPortConflict(port: number) {
|
|
1906
|
+
const portInput = $('process-port-input') as HTMLInputElement | null;
|
|
1907
|
+
if (!portInput || !port || port < 1 || port > 65535) return;
|
|
1908
|
+
try {
|
|
1909
|
+
const res = await fetch(`/api/check-port?port=${port}`);
|
|
1910
|
+
const data = await res.json();
|
|
1911
|
+
if (data.inUse) {
|
|
1912
|
+
portInput.classList.add('port-conflict');
|
|
1913
|
+
portInput.title = `Port ${port} is already in use`;
|
|
1914
|
+
showToast(`⚠️ Port ${port} is already in use`, 'error');
|
|
1915
|
+
} else {
|
|
1916
|
+
portInput.classList.remove('port-conflict');
|
|
1917
|
+
portInput.title = '';
|
|
1918
|
+
}
|
|
1919
|
+
} catch { /* best effort */ }
|
|
1567
1920
|
}
|
|
1568
1921
|
|
|
1922
|
+
$('process-port-input')?.addEventListener('input', () => {
|
|
1923
|
+
const portInput = $('process-port-input') as HTMLInputElement | null;
|
|
1924
|
+
if (!portInput) return;
|
|
1925
|
+
portInput.classList.remove('port-conflict');
|
|
1926
|
+
portInput.title = '';
|
|
1927
|
+
if (portCheckDebounce) clearTimeout(portCheckDebounce);
|
|
1928
|
+
const val = parseInt(portInput.value);
|
|
1929
|
+
if (val && val > 0 && val <= 65535) {
|
|
1930
|
+
portCheckDebounce = setTimeout(() => checkPortConflict(val), 500);
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
// Port range preference
|
|
1935
|
+
const savedPortRange = (() => {
|
|
1936
|
+
try { return JSON.parse(localStorage.getItem('bgr_port_range') || 'null'); } catch { return null; }
|
|
1937
|
+
})();
|
|
1938
|
+
const portRangeMin = $('port-range-min') as HTMLInputElement | null;
|
|
1939
|
+
const portRangeMax = $('port-range-max') as HTMLInputElement | null;
|
|
1940
|
+
if (portRangeMin && savedPortRange?.min) portRangeMin.value = String(savedPortRange.min);
|
|
1941
|
+
if (portRangeMax && savedPortRange?.max) portRangeMax.value = String(savedPortRange.max);
|
|
1942
|
+
|
|
1943
|
+
function validatePortRange() {
|
|
1944
|
+
const min = parseInt(portRangeMin?.value || '') || 0;
|
|
1945
|
+
const max = parseInt(portRangeMax?.value || '') || 0;
|
|
1946
|
+
const hasMin = min > 0;
|
|
1947
|
+
const hasMax = max > 0;
|
|
1948
|
+
const effectiveMin = min || 3001;
|
|
1949
|
+
const effectiveMax = max || 65535;
|
|
1950
|
+
|
|
1951
|
+
portRangeMin?.classList.remove('port-conflict');
|
|
1952
|
+
portRangeMax?.classList.remove('port-conflict');
|
|
1953
|
+
|
|
1954
|
+
if (hasMin && hasMax && effectiveMin > effectiveMax) {
|
|
1955
|
+
portRangeMin?.classList.add('port-conflict');
|
|
1956
|
+
portRangeMax?.classList.add('port-conflict');
|
|
1957
|
+
showToast(`⚠️ Port range invalid: min (${effectiveMin}) > max (${effectiveMax})`, 'error');
|
|
1958
|
+
return false;
|
|
1959
|
+
}
|
|
1960
|
+
if (hasMin && hasMax && effectiveMax - effectiveMin < 5) {
|
|
1961
|
+
showToast(`Port range is very narrow (${effectiveMax - effectiveMin + 1} ports)`, 'info');
|
|
1962
|
+
}
|
|
1963
|
+
return true;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function savePortRange() {
|
|
1967
|
+
const min = parseInt(portRangeMin?.value || '') || 0;
|
|
1968
|
+
const max = parseInt(portRangeMax?.value || '') || 0;
|
|
1969
|
+
if (min > 0 || max > 0) {
|
|
1970
|
+
localStorage.setItem('bgr_port_range', JSON.stringify({ min: min || 3001, max: max || 65535 }));
|
|
1971
|
+
} else {
|
|
1972
|
+
localStorage.removeItem('bgr_port_range');
|
|
1973
|
+
}
|
|
1974
|
+
validatePortRange();
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
portRangeMin?.addEventListener('change', savePortRange);
|
|
1978
|
+
portRangeMax?.addEventListener('change', savePortRange);
|
|
1979
|
+
|
|
1980
|
+
$('suggest-port-btn')?.addEventListener('click', async () => {
|
|
1981
|
+
const portInput = $('process-port-input') as HTMLInputElement | null;
|
|
1982
|
+
if (!portInput) return;
|
|
1983
|
+
if (!validatePortRange()) return;
|
|
1984
|
+
const base = parseInt(portRangeMin?.value || '') || 3001;
|
|
1985
|
+
const max = parseInt(portRangeMax?.value || '') || 65535;
|
|
1986
|
+
try {
|
|
1987
|
+
const res = await fetch(`/api/next-port?base=${base}`);
|
|
1988
|
+
const data = await res.json();
|
|
1989
|
+
if (data.port > max) {
|
|
1990
|
+
showToast(`⚠️ No available port in range ${base}–${max}`, 'error');
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
portInput.value = String(data.port);
|
|
1994
|
+
portInput.classList.remove('port-conflict');
|
|
1995
|
+
portInput.title = '';
|
|
1996
|
+
showToast(`Suggested port ${data.port}`, 'success');
|
|
1997
|
+
await checkPortConflict(data.port);
|
|
1998
|
+
} catch {
|
|
1999
|
+
showToast('Failed to fetch next port', 'error');
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
|
|
1569
2003
|
async function createProcess() {
|
|
1570
2004
|
const name = ($('process-name-input') as HTMLInputElement)?.value?.trim();
|
|
1571
2005
|
const command = ($('process-command-input') as HTMLInputElement)?.value?.trim();
|
|
1572
2006
|
const directory = ($('process-directory-input') as HTMLInputElement)?.value?.trim();
|
|
2007
|
+
const portValue = ($('process-port-input') as HTMLInputElement)?.value?.trim();
|
|
1573
2008
|
|
|
1574
2009
|
if (!name || !command || !directory) {
|
|
1575
2010
|
showToast('Please fill in all fields', 'error');
|
|
1576
2011
|
return;
|
|
1577
2012
|
}
|
|
1578
2013
|
|
|
2014
|
+
const body: Record<string, any> = { name, command, directory };
|
|
2015
|
+
if (portValue) {
|
|
2016
|
+
body.env = { PORT: portValue };
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1579
2019
|
try {
|
|
1580
2020
|
const res = await fetch('/api/start', {
|
|
1581
2021
|
method: 'POST',
|
|
1582
2022
|
headers: { 'Content-Type': 'application/json' },
|
|
1583
|
-
body: JSON.stringify(
|
|
2023
|
+
body: JSON.stringify(body),
|
|
1584
2024
|
});
|
|
1585
2025
|
|
|
1586
2026
|
if (res.ok) {
|
|
@@ -1608,6 +2048,1295 @@ export default function mount(): () => void {
|
|
|
1608
2048
|
}
|
|
1609
2049
|
});
|
|
1610
2050
|
|
|
2051
|
+
// ─── Dependencies Modal ───
|
|
2052
|
+
|
|
2053
|
+
interface DepGraphData {
|
|
2054
|
+
graph: Record<string, string[]>;
|
|
2055
|
+
startOrder: string[];
|
|
2056
|
+
processes: { name: string; group: string; pid: number }[];
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
let depsData: DepGraphData | null = null;
|
|
2060
|
+
|
|
2061
|
+
function openDepsModal() {
|
|
2062
|
+
const modal = $('deps-modal');
|
|
2063
|
+
if (modal) modal.classList.add('active');
|
|
2064
|
+
loadDepsGraph();
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function closeDepsModal() {
|
|
2068
|
+
const modal = $('deps-modal');
|
|
2069
|
+
if (modal) modal.classList.remove('active');
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
async function loadDepsGraph() {
|
|
2073
|
+
try {
|
|
2074
|
+
const res = await fetch('/api/dependencies');
|
|
2075
|
+
depsData = await res.json();
|
|
2076
|
+
if (depsData) {
|
|
2077
|
+
populateDepsSelects(depsData.processes);
|
|
2078
|
+
renderDepsGraph(depsData);
|
|
2079
|
+
renderDepsList(depsData);
|
|
2080
|
+
renderDepsStartOrder(depsData);
|
|
2081
|
+
}
|
|
2082
|
+
} catch (e) {
|
|
2083
|
+
console.error('Failed to load dependencies', e);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function populateDepsSelects(processes: { name: string }[]) {
|
|
2088
|
+
const procSelect = $('deps-process-select') as HTMLSelectElement;
|
|
2089
|
+
const targetSelect = $('deps-target-select') as HTMLSelectElement;
|
|
2090
|
+
if (!procSelect || !targetSelect) return;
|
|
2091
|
+
|
|
2092
|
+
const names = processes.map(p => p.name).sort();
|
|
2093
|
+
for (const sel of [procSelect, targetSelect]) {
|
|
2094
|
+
const val = sel.value;
|
|
2095
|
+
sel.innerHTML = `<option value="">Select process...</option>`;
|
|
2096
|
+
for (const name of names) {
|
|
2097
|
+
sel.innerHTML += `<option value="${name}">${name}</option>`;
|
|
2098
|
+
}
|
|
2099
|
+
sel.value = val;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function renderDepsGraph(data: DepGraphData) {
|
|
2104
|
+
const container = $('deps-graph-container');
|
|
2105
|
+
const svg = document.getElementById('deps-graph-svg');
|
|
2106
|
+
if (!svg || !container) return;
|
|
2107
|
+
|
|
2108
|
+
const processes = data.processes;
|
|
2109
|
+
const graph = data.graph;
|
|
2110
|
+
if (processes.length === 0) {
|
|
2111
|
+
svg.innerHTML = `<text x="50%" y="50%" text-anchor="middle" fill="var(--text-secondary)" font-size="14">No processes registered</text>`;
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// Build adjacency for layout
|
|
2116
|
+
const names = processes.map(p => p.name);
|
|
2117
|
+
const nameSet = new Set(names);
|
|
2118
|
+
|
|
2119
|
+
// Topological layers using BFS from roots
|
|
2120
|
+
const inDeg: Record<string, number> = {};
|
|
2121
|
+
for (const n of names) inDeg[n] = 0;
|
|
2122
|
+
for (const [proc, deps] of Object.entries(graph)) {
|
|
2123
|
+
if (nameSet.has(proc)) {
|
|
2124
|
+
for (const d of deps) {
|
|
2125
|
+
if (nameSet.has(d)) inDeg[proc] = (inDeg[proc] || 0) + 1;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// Assign layers (depth from roots)
|
|
2131
|
+
const layers: string[][] = [];
|
|
2132
|
+
const assigned = new Set<string>();
|
|
2133
|
+
let currentLayer = names.filter(n => (inDeg[n] || 0) === 0);
|
|
2134
|
+
|
|
2135
|
+
while (currentLayer.length > 0) {
|
|
2136
|
+
currentLayer.sort();
|
|
2137
|
+
layers.push(currentLayer);
|
|
2138
|
+
for (const n of currentLayer) assigned.add(n);
|
|
2139
|
+
|
|
2140
|
+
const nextLayer: string[] = [];
|
|
2141
|
+
for (const n of currentLayer) {
|
|
2142
|
+
// Find processes that depend on n
|
|
2143
|
+
for (const [proc, deps] of Object.entries(graph)) {
|
|
2144
|
+
if (deps.includes(n) && nameSet.has(proc) && !assigned.has(proc)) {
|
|
2145
|
+
// Check if all deps of proc are assigned
|
|
2146
|
+
const allDepsAssigned = (graph[proc] || []).every(d => !nameSet.has(d) || assigned.has(d));
|
|
2147
|
+
if (allDepsAssigned && !nextLayer.includes(proc)) {
|
|
2148
|
+
nextLayer.push(proc);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
currentLayer = nextLayer;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// Add unassigned (isolated or in cycles)
|
|
2157
|
+
const remaining = names.filter(n => !assigned.has(n));
|
|
2158
|
+
if (remaining.length > 0) layers.push(remaining);
|
|
2159
|
+
|
|
2160
|
+
// Layout: horizontal layers, left to right
|
|
2161
|
+
const nodeW = 130, nodeH = 36;
|
|
2162
|
+
const layerGap = 180, nodeGap = 52;
|
|
2163
|
+
const padX = 40, padY = 30;
|
|
2164
|
+
|
|
2165
|
+
const positions: Record<string, { x: number; y: number }> = {};
|
|
2166
|
+
const maxLayerSize = Math.max(...layers.map(l => l.length));
|
|
2167
|
+
const totalW = padX * 2 + layers.length * layerGap;
|
|
2168
|
+
const totalH = padY * 2 + maxLayerSize * nodeGap;
|
|
2169
|
+
|
|
2170
|
+
for (let li = 0; li < layers.length; li++) {
|
|
2171
|
+
const layer = layers[li];
|
|
2172
|
+
const layerH = layer.length * nodeGap;
|
|
2173
|
+
const offsetY = (totalH - layerH) / 2;
|
|
2174
|
+
for (let ni = 0; ni < layer.length; ni++) {
|
|
2175
|
+
positions[layer[ni]] = {
|
|
2176
|
+
x: padX + li * layerGap,
|
|
2177
|
+
y: offsetY + ni * nodeGap,
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
svg.setAttribute('width', String(Math.max(totalW, 600)));
|
|
2183
|
+
svg.setAttribute('height', String(Math.max(totalH, 300)));
|
|
2184
|
+
svg.setAttribute('viewBox', `0 0 ${Math.max(totalW, 600)} ${Math.max(totalH, 300)}`);
|
|
2185
|
+
|
|
2186
|
+
// Find running processes
|
|
2187
|
+
const runningNames = new Set(
|
|
2188
|
+
(allProcesses || []).filter((p: ProcessData) => p.running).map((p: ProcessData) => p.name)
|
|
2189
|
+
);
|
|
2190
|
+
|
|
2191
|
+
let svgContent = `
|
|
2192
|
+
<defs>
|
|
2193
|
+
<marker id="deps-arrowhead" viewBox="0 0 10 7" refX="10" refY="3.5"
|
|
2194
|
+
markerWidth="8" markerHeight="6" orient="auto-start-reverse">
|
|
2195
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="var(--text-secondary)" opacity="0.6" />
|
|
2196
|
+
</marker>
|
|
2197
|
+
<marker id="deps-arrowhead-hl" viewBox="0 0 10 7" refX="10" refY="3.5"
|
|
2198
|
+
markerWidth="8" markerHeight="6" orient="auto-start-reverse">
|
|
2199
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="var(--accent)" />
|
|
2200
|
+
</marker>
|
|
2201
|
+
</defs>
|
|
2202
|
+
`;
|
|
2203
|
+
|
|
2204
|
+
// Draw edges (dependency arrows: depends_on ← process, so arrow from dep → process)
|
|
2205
|
+
for (const [proc, deps] of Object.entries(graph)) {
|
|
2206
|
+
if (!positions[proc]) continue;
|
|
2207
|
+
for (const dep of deps) {
|
|
2208
|
+
if (!positions[dep]) continue;
|
|
2209
|
+
const from = positions[dep];
|
|
2210
|
+
const to = positions[proc];
|
|
2211
|
+
const x1 = from.x + nodeW;
|
|
2212
|
+
const y1 = from.y + nodeH / 2;
|
|
2213
|
+
const x2 = to.x;
|
|
2214
|
+
const y2 = to.y + nodeH / 2;
|
|
2215
|
+
const cx1 = x1 + (x2 - x1) * 0.4;
|
|
2216
|
+
const cx2 = x2 - (x2 - x1) * 0.4;
|
|
2217
|
+
svgContent += `<path class="deps-edge" d="M${x1},${y1} C${cx1},${y1} ${cx2},${y2} ${x2},${y2}" data-from="${dep}" data-to="${proc}" />`;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// Draw nodes
|
|
2222
|
+
for (const name of names) {
|
|
2223
|
+
const pos = positions[name];
|
|
2224
|
+
if (!pos) continue;
|
|
2225
|
+
const isRunning = runningNames.has(name);
|
|
2226
|
+
const fillColor = isRunning ? 'rgba(34,197,94,0.18)' : 'rgba(100,100,120,0.15)';
|
|
2227
|
+
const strokeColor = isRunning ? 'rgba(34,197,94,0.6)' : 'rgba(100,100,120,0.3)';
|
|
2228
|
+
const statusDot = isRunning ? '🟢' : '⚫';
|
|
2229
|
+
|
|
2230
|
+
svgContent += `
|
|
2231
|
+
<g class="deps-node" data-name="${name}">
|
|
2232
|
+
<rect x="${pos.x}" y="${pos.y}" width="${nodeW}" height="${nodeH}"
|
|
2233
|
+
fill="${fillColor}" stroke="${strokeColor}" />
|
|
2234
|
+
<text x="${pos.x + 22}" y="${pos.y + nodeH / 2 + 4}" font-size="11">${name.length > 14 ? name.slice(0, 13) + '…' : name}</text>
|
|
2235
|
+
<text x="${pos.x + 6}" y="${pos.y + nodeH / 2 + 5}" font-size="10">${statusDot}</text>
|
|
2236
|
+
</g>
|
|
2237
|
+
`;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
svg.innerHTML = svgContent;
|
|
2241
|
+
|
|
2242
|
+
// Hover highlighting
|
|
2243
|
+
svg.querySelectorAll('.deps-node').forEach(node => {
|
|
2244
|
+
node.addEventListener('mouseenter', () => {
|
|
2245
|
+
const name = (node as Element).getAttribute('data-name');
|
|
2246
|
+
svg.querySelectorAll('.deps-edge').forEach(edge => {
|
|
2247
|
+
const from = edge.getAttribute('data-from');
|
|
2248
|
+
const to = edge.getAttribute('data-to');
|
|
2249
|
+
if (from === name || to === name) {
|
|
2250
|
+
edge.classList.add('deps-edge-highlight');
|
|
2251
|
+
(edge as SVGElement).style.markerEnd = 'url(#deps-arrowhead-hl)';
|
|
2252
|
+
}
|
|
2253
|
+
});
|
|
2254
|
+
});
|
|
2255
|
+
node.addEventListener('mouseleave', () => {
|
|
2256
|
+
svg.querySelectorAll('.deps-edge').forEach(edge => {
|
|
2257
|
+
edge.classList.remove('deps-edge-highlight');
|
|
2258
|
+
(edge as SVGElement).style.markerEnd = 'url(#deps-arrowhead)';
|
|
2259
|
+
});
|
|
2260
|
+
});
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
function renderDepsList(data: DepGraphData) {
|
|
2265
|
+
const container = $('deps-list');
|
|
2266
|
+
if (!container) return;
|
|
2267
|
+
|
|
2268
|
+
const entries: { process: string; dep: string }[] = [];
|
|
2269
|
+
for (const [proc, deps] of Object.entries(data.graph)) {
|
|
2270
|
+
for (const dep of deps) {
|
|
2271
|
+
entries.push({ process: proc, dep });
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (entries.length === 0) {
|
|
2276
|
+
container.innerHTML = `<div class="deps-empty">No dependencies configured. Use the dropdowns above to add one.</div>`;
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
container.innerHTML = entries.map(e => `
|
|
2281
|
+
<div class="deps-list-item">
|
|
2282
|
+
<span class="deps-item-process">${e.process}</span>
|
|
2283
|
+
<span class="deps-item-arrow">→ depends on →</span>
|
|
2284
|
+
<span class="deps-item-target">${e.dep}</span>
|
|
2285
|
+
<button class="deps-remove-btn" data-process="${e.process}" data-dep="${e.dep}" title="Remove dependency">✕</button>
|
|
2286
|
+
</div>
|
|
2287
|
+
`).join('');
|
|
2288
|
+
|
|
2289
|
+
// Remove buttons
|
|
2290
|
+
container.querySelectorAll('.deps-remove-btn').forEach(btn => {
|
|
2291
|
+
btn.addEventListener('click', async () => {
|
|
2292
|
+
const proc = btn.getAttribute('data-process');
|
|
2293
|
+
const dep = btn.getAttribute('data-dep');
|
|
2294
|
+
await fetch('/api/dependencies', {
|
|
2295
|
+
method: 'DELETE',
|
|
2296
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2297
|
+
body: JSON.stringify({ process: proc, depends_on: dep }),
|
|
2298
|
+
});
|
|
2299
|
+
loadDepsGraph();
|
|
2300
|
+
});
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
function renderDepsStartOrder(data: DepGraphData) {
|
|
2305
|
+
const container = $('deps-start-order');
|
|
2306
|
+
if (!container) return;
|
|
2307
|
+
|
|
2308
|
+
if (data.startOrder.length === 0) {
|
|
2309
|
+
container.innerHTML = '';
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
container.innerHTML = `
|
|
2314
|
+
<div class="deps-order-title">⚡ Recommended Start Order</div>
|
|
2315
|
+
<div class="deps-order-list">
|
|
2316
|
+
${data.startOrder.map((name, i) => `
|
|
2317
|
+
<span class="deps-order-badge">
|
|
2318
|
+
<span class="deps-order-num">${i + 1}</span>
|
|
2319
|
+
${name}
|
|
2320
|
+
</span>
|
|
2321
|
+
`).join('')}
|
|
2322
|
+
</div>
|
|
2323
|
+
`;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
$('deps-btn')?.addEventListener('click', openDepsModal);
|
|
2327
|
+
$('deps-modal-close')?.addEventListener('click', closeDepsModal);
|
|
2328
|
+
$('deps-modal')?.addEventListener('click', (e) => {
|
|
2329
|
+
if ((e.target as Element).classList.contains('modal-overlay')) closeDepsModal();
|
|
2330
|
+
});
|
|
2331
|
+
|
|
2332
|
+
$('deps-add-btn')?.addEventListener('click', async () => {
|
|
2333
|
+
const proc = ($('deps-process-select') as HTMLSelectElement)?.value;
|
|
2334
|
+
const dep = ($('deps-target-select') as HTMLSelectElement)?.value;
|
|
2335
|
+
if (!proc || !dep) {
|
|
2336
|
+
showToast('Select both a process and its dependency', 'error');
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
if (proc === dep) {
|
|
2340
|
+
showToast('A process cannot depend on itself', 'error');
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
const res = await fetch('/api/dependencies', {
|
|
2344
|
+
method: 'POST',
|
|
2345
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2346
|
+
body: JSON.stringify({ process: proc, depends_on: dep }),
|
|
2347
|
+
});
|
|
2348
|
+
const result = await res.json();
|
|
2349
|
+
if (!res.ok) {
|
|
2350
|
+
showToast(result.error || 'Failed to add dependency', 'error');
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
showToast(`${proc} → ${dep} dependency added`, 'success');
|
|
2354
|
+
loadDepsGraph();
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
// ─── Templates Modal ───
|
|
2358
|
+
|
|
2359
|
+
interface TemplateData {
|
|
2360
|
+
name: string;
|
|
2361
|
+
command: string;
|
|
2362
|
+
workdir: string;
|
|
2363
|
+
env: string;
|
|
2364
|
+
group: string;
|
|
2365
|
+
created_at: string;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
let templates: TemplateData[] = [];
|
|
2369
|
+
|
|
2370
|
+
async function loadTemplates() {
|
|
2371
|
+
try {
|
|
2372
|
+
const res = await fetch('/api/templates');
|
|
2373
|
+
if (res.ok) {
|
|
2374
|
+
templates = await res.json();
|
|
2375
|
+
renderTemplates();
|
|
2376
|
+
}
|
|
2377
|
+
} catch (err) {
|
|
2378
|
+
console.error('[bgr-dashboard] loadTemplates error:', err);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function renderTemplates() {
|
|
2383
|
+
const list = $('templates-list');
|
|
2384
|
+
if (!list) return;
|
|
2385
|
+
|
|
2386
|
+
if (templates.length === 0) {
|
|
2387
|
+
list.innerHTML = '<div class="templates-empty">No templates saved yet</div>';
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
list.replaceChildren(...templates.map(t => (
|
|
2392
|
+
<div className="template-item">
|
|
2393
|
+
<div className="template-item-info">
|
|
2394
|
+
<div className="template-item-name">{t.name}</div>
|
|
2395
|
+
<div className="template-item-command">{t.command}</div>
|
|
2396
|
+
</div>
|
|
2397
|
+
{t.group && <span className="template-item-group">{t.group}</span>}
|
|
2398
|
+
<div className="template-item-actions">
|
|
2399
|
+
<button className="use-btn" data-use={t.name} title="Use this template">Use</button>
|
|
2400
|
+
<button className="delete-btn" data-delete={t.name} title="Delete template">✕</button>
|
|
2401
|
+
</div>
|
|
2402
|
+
</div>
|
|
2403
|
+
) as unknown as Node));
|
|
2404
|
+
|
|
2405
|
+
// Add click handlers
|
|
2406
|
+
list.querySelectorAll('.use-btn').forEach(btn => {
|
|
2407
|
+
btn.addEventListener('click', (e) => {
|
|
2408
|
+
const name = (e.target as HTMLElement).dataset.use;
|
|
2409
|
+
const tmpl = templates.find(t => t.name === name);
|
|
2410
|
+
if (tmpl) {
|
|
2411
|
+
useTemplate(tmpl);
|
|
2412
|
+
}
|
|
2413
|
+
});
|
|
2414
|
+
});
|
|
2415
|
+
|
|
2416
|
+
list.querySelectorAll('.delete-btn').forEach(btn => {
|
|
2417
|
+
btn.addEventListener('click', (e) => {
|
|
2418
|
+
const name = (e.target as HTMLElement).dataset.delete;
|
|
2419
|
+
if (name) deleteTemplate(name);
|
|
2420
|
+
});
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
function openTemplatesModal() {
|
|
2425
|
+
const modal = $('templates-modal');
|
|
2426
|
+
if (modal) modal.classList.add('active');
|
|
2427
|
+
loadTemplates();
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function closeTemplatesModal() {
|
|
2431
|
+
const modal = $('templates-modal');
|
|
2432
|
+
if (modal) modal.classList.remove('active');
|
|
2433
|
+
// Clear form
|
|
2434
|
+
($('template-name') as HTMLInputElement).value = '';
|
|
2435
|
+
($('template-command') as HTMLInputElement).value = '';
|
|
2436
|
+
($('template-directory') as HTMLInputElement).value = '';
|
|
2437
|
+
($('template-group') as HTMLInputElement).value = '';
|
|
2438
|
+
($('template-env') as HTMLInputElement).value = '';
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
async function saveTemplate() {
|
|
2442
|
+
const name = ($('template-name') as HTMLInputElement)?.value?.trim();
|
|
2443
|
+
const command = ($('template-command') as HTMLInputElement)?.value?.trim();
|
|
2444
|
+
const workdir = ($('template-directory') as HTMLInputElement)?.value?.trim();
|
|
2445
|
+
const group = ($('template-group') as HTMLInputElement)?.value?.trim();
|
|
2446
|
+
const env = ($('template-env') as HTMLInputElement)?.value?.trim();
|
|
2447
|
+
|
|
2448
|
+
if (!name || !command) {
|
|
2449
|
+
showToast('Name and command are required', 'error');
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
try {
|
|
2454
|
+
const res = await fetch('/api/templates', {
|
|
2455
|
+
method: 'POST',
|
|
2456
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2457
|
+
body: JSON.stringify({ name, command, workdir, group, env }),
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
if (res.ok) {
|
|
2461
|
+
showToast(`Template "${name}" saved`, 'success');
|
|
2462
|
+
loadTemplates();
|
|
2463
|
+
// Clear form
|
|
2464
|
+
($('template-name') as HTMLInputElement).value = '';
|
|
2465
|
+
($('template-command') as HTMLInputElement).value = '';
|
|
2466
|
+
($('template-directory') as HTMLInputElement).value = '';
|
|
2467
|
+
($('template-group') as HTMLInputElement).value = '';
|
|
2468
|
+
($('template-env') as HTMLInputElement).value = '';
|
|
2469
|
+
} else {
|
|
2470
|
+
showToast('Failed to save template', 'error');
|
|
2471
|
+
}
|
|
2472
|
+
} catch (err) {
|
|
2473
|
+
showToast('Failed to save template', 'error');
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
async function deleteTemplate(name: string) {
|
|
2478
|
+
if (!confirm(`Delete template "${name}"?`)) return;
|
|
2479
|
+
|
|
2480
|
+
try {
|
|
2481
|
+
const res = await fetch(`/api/templates?name=${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
2482
|
+
if (res.ok) {
|
|
2483
|
+
showToast(`Template "${name}" deleted`, 'success');
|
|
2484
|
+
loadTemplates();
|
|
2485
|
+
} else {
|
|
2486
|
+
showToast('Failed to delete template', 'error');
|
|
2487
|
+
}
|
|
2488
|
+
} catch (err) {
|
|
2489
|
+
showToast('Failed to delete template', 'error');
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
function useTemplate(tmpl: TemplateData) {
|
|
2494
|
+
// Fill new process form with template values
|
|
2495
|
+
($('process-name-input') as HTMLInputElement).value = '';
|
|
2496
|
+
($('process-command-input') as HTMLInputElement).value = tmpl.command;
|
|
2497
|
+
($('process-directory-input') as HTMLInputElement).value = tmpl.workdir;
|
|
2498
|
+
closeTemplatesModal();
|
|
2499
|
+
openModal();
|
|
2500
|
+
showToast(`Template "${tmpl.name}" loaded — enter a process name`, 'success');
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
$('templates-btn')?.addEventListener('click', openTemplatesModal);
|
|
2504
|
+
$('templates-modal-close')?.addEventListener('click', closeTemplatesModal);
|
|
2505
|
+
$('template-save-btn')?.addEventListener('click', saveTemplate);
|
|
2506
|
+
$('templates-modal')?.addEventListener('click', (e) => {
|
|
2507
|
+
if ((e.target as Element).classList.contains('modal-overlay')) {
|
|
2508
|
+
closeTemplatesModal();
|
|
2509
|
+
}
|
|
2510
|
+
});
|
|
2511
|
+
|
|
2512
|
+
// ─── History Modal ───
|
|
2513
|
+
|
|
2514
|
+
interface HistoryEntry {
|
|
2515
|
+
process_name: string;
|
|
2516
|
+
event: string;
|
|
2517
|
+
pid: number | null;
|
|
2518
|
+
timestamp: string;
|
|
2519
|
+
metadata: Record<string, any>;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
interface DeployResultEntry {
|
|
2523
|
+
name: string;
|
|
2524
|
+
ok: boolean;
|
|
2525
|
+
skipped?: boolean;
|
|
2526
|
+
reason?: string;
|
|
2527
|
+
pullOutput?: string;
|
|
2528
|
+
installOutput?: string;
|
|
2529
|
+
packageManager?: string | null;
|
|
2530
|
+
installCommand?: string;
|
|
2531
|
+
installAttempted?: boolean;
|
|
2532
|
+
retrying?: boolean;
|
|
2533
|
+
phase?: 'pending' | 'running' | 'done';
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
let allHistory: HistoryEntry[] = [];
|
|
2537
|
+
let latestDeployResults: DeployResultEntry[] = [];
|
|
2538
|
+
let latestDeploySummary: { group?: string | null; deployed?: number; skipped?: number; failed?: number; total?: number } | undefined;
|
|
2539
|
+
const historyFocusStorageKey = 'bgr_history_focus_target';
|
|
2540
|
+
let pendingHistoryFocus: { process?: string; event?: string } | null = (() => {
|
|
2541
|
+
try {
|
|
2542
|
+
const raw = sessionStorage.getItem(historyFocusStorageKey);
|
|
2543
|
+
return raw ? JSON.parse(raw) : null;
|
|
2544
|
+
} catch {
|
|
2545
|
+
return null;
|
|
2546
|
+
}
|
|
2547
|
+
})();
|
|
2548
|
+
|
|
2549
|
+
async function loadHistory() {
|
|
2550
|
+
try {
|
|
2551
|
+
const res = await fetch('/api/history?limit=100');
|
|
2552
|
+
if (res.ok) {
|
|
2553
|
+
allHistory = await res.json();
|
|
2554
|
+
renderHistory();
|
|
2555
|
+
updateHistoryFilters();
|
|
2556
|
+
}
|
|
2557
|
+
} catch (err) {
|
|
2558
|
+
console.error('[bgr-dashboard] loadHistory error:', err);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
function updateHistoryFilters() {
|
|
2563
|
+
const processFilter = $('history-process-filter') as HTMLSelectElement;
|
|
2564
|
+
const eventFilter = $('history-event-filter') as HTMLSelectElement;
|
|
2565
|
+
if (!processFilter) return;
|
|
2566
|
+
|
|
2567
|
+
const processNames = new Set<string>();
|
|
2568
|
+
for (const h of allHistory) {
|
|
2569
|
+
processNames.add(h.process_name);
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
const currentValue = processFilter.value;
|
|
2573
|
+
processFilter.replaceChildren(
|
|
2574
|
+
<option value="">All Processes</option> as unknown as Node,
|
|
2575
|
+
...Array.from(processNames).sort().map(n => <option value={n}>{n}</option> as unknown as Node)
|
|
2576
|
+
);
|
|
2577
|
+
if (currentValue && processNames.has(currentValue)) {
|
|
2578
|
+
processFilter.value = currentValue;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
function formatHistoryDetails(h: HistoryEntry): Array<{ label: string; value: string; copyable?: boolean }> {
|
|
2583
|
+
const md = h.metadata || {};
|
|
2584
|
+
const parts: Array<{ label: string; value: string; copyable?: boolean }> = [];
|
|
2585
|
+
|
|
2586
|
+
if (h.event === 'deploy') {
|
|
2587
|
+
if (md.packageManager) parts.push({ label: 'pm', value: String(md.packageManager) });
|
|
2588
|
+
if (md.installCommand) parts.push({ label: 'install', value: String(md.installCommand), copyable: true });
|
|
2589
|
+
else if (md.installed === false) parts.push({ label: 'install', value: 'skipped' });
|
|
2590
|
+
if (md.directory) parts.push({ label: 'dir', value: String(md.directory), copyable: true });
|
|
2591
|
+
} else {
|
|
2592
|
+
if (md.by) parts.push({ label: 'by', value: String(md.by) });
|
|
2593
|
+
if (md.count !== undefined) parts.push({ label: 'count', value: String(md.count) });
|
|
2594
|
+
if (md.directory) parts.push({ label: 'dir', value: String(md.directory), copyable: true });
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
return parts;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
function setHistoryFocusTarget(target: { process?: string; event?: string } | null) {
|
|
2601
|
+
pendingHistoryFocus = target;
|
|
2602
|
+
try {
|
|
2603
|
+
if (target) sessionStorage.setItem(historyFocusStorageKey, JSON.stringify(target));
|
|
2604
|
+
else sessionStorage.removeItem(historyFocusStorageKey);
|
|
2605
|
+
} catch { }
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function applyHistoryDensity() {
|
|
2609
|
+
const list = $('history-list');
|
|
2610
|
+
const select = $('history-density-select') as HTMLSelectElement | null;
|
|
2611
|
+
if (select) select.value = historyDensity;
|
|
2612
|
+
if (!list) return;
|
|
2613
|
+
list.classList.toggle('history-density-compact', historyDensity === 'compact');
|
|
2614
|
+
list.classList.toggle('history-density-cozy', historyDensity !== 'compact');
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function applyHistoryShortcutPreference() {
|
|
2618
|
+
const list = $('history-list');
|
|
2619
|
+
const toggle = $('history-shortcuts-toggle') as HTMLInputElement | null;
|
|
2620
|
+
if (toggle) toggle.checked = historyShortcutsEnabled;
|
|
2621
|
+
if (!list) return;
|
|
2622
|
+
list.classList.toggle('history-shortcuts-hidden', !historyShortcutsEnabled);
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
function applyHistoryDetailsPreference() {
|
|
2626
|
+
const select = $('history-details-default-select') as HTMLSelectElement | null;
|
|
2627
|
+
if (select) select.value = historyDetailsDefault;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
function getMatchingHistoryHintPreset(): 'minimal' | 'navigation' | 'all' | 'custom' {
|
|
2631
|
+
const { nav, open, filter, details, close } = historyHintGroups;
|
|
2632
|
+
if (nav && open && !filter && !details && !close) return 'minimal';
|
|
2633
|
+
if (nav && open && filter && !details && close) return 'navigation';
|
|
2634
|
+
if (nav && open && filter && details && close) return 'all';
|
|
2635
|
+
return 'custom';
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
function setHistoryHintPreset(preset: 'minimal' | 'navigation' | 'all') {
|
|
2639
|
+
if (preset === 'minimal') {
|
|
2640
|
+
historyHintGroups = { nav: true, open: true, filter: false, details: false, close: false };
|
|
2641
|
+
} else if (preset === 'navigation') {
|
|
2642
|
+
historyHintGroups = { nav: true, open: true, filter: true, details: false, close: true };
|
|
2643
|
+
} else {
|
|
2644
|
+
historyHintGroups = { nav: true, open: true, filter: true, details: true, close: true };
|
|
2645
|
+
}
|
|
2646
|
+
localStorage.setItem('bgr_history_hint_groups', JSON.stringify(historyHintGroups));
|
|
2647
|
+
applyHistoryHintsPreference();
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function applyHistoryHintsPreference() {
|
|
2651
|
+
const hints = $('history-keyboard-hints');
|
|
2652
|
+
const toggle = $('history-hints-toggle') as HTMLButtonElement | null;
|
|
2653
|
+
const densitySelect = $('history-hint-density-select') as HTMLSelectElement | null;
|
|
2654
|
+
const presetState = $('history-hint-preset-state');
|
|
2655
|
+
const matchedPreset = getMatchingHistoryHintPreset();
|
|
2656
|
+
const groups = {
|
|
2657
|
+
nav: $('history-hint-group-nav') as HTMLInputElement | null,
|
|
2658
|
+
open: $('history-hint-group-open') as HTMLInputElement | null,
|
|
2659
|
+
filter: $('history-hint-group-filter') as HTMLInputElement | null,
|
|
2660
|
+
details: $('history-hint-group-details') as HTMLInputElement | null,
|
|
2661
|
+
close: $('history-hint-group-close') as HTMLInputElement | null,
|
|
2662
|
+
};
|
|
2663
|
+
if (hints) {
|
|
2664
|
+
hints.style.display = historyHintsVisible ? '' : 'none';
|
|
2665
|
+
hints.classList.toggle('compact', historyHintDensity === 'compact');
|
|
2666
|
+
hints.classList.toggle('full', historyHintDensity !== 'compact');
|
|
2667
|
+
(Object.entries(historyHintGroups) as Array<[string, boolean]>).forEach(([group, enabled]) => {
|
|
2668
|
+
hints.querySelectorAll(`[data-hint-group="${group}"]`).forEach(el => {
|
|
2669
|
+
(el as HTMLElement).style.display = enabled ? '' : 'none';
|
|
2670
|
+
});
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
if (densitySelect) densitySelect.value = historyHintDensity;
|
|
2674
|
+
(Object.entries(groups) as Array<[keyof typeof groups, HTMLInputElement | null]>).forEach(([group, input]) => {
|
|
2675
|
+
if (input) input.checked = !!historyHintGroups[group];
|
|
2676
|
+
});
|
|
2677
|
+
document.querySelectorAll('[data-history-hint-preset]').forEach(el => {
|
|
2678
|
+
const preset = (el as HTMLElement).dataset.historyHintPreset;
|
|
2679
|
+
el.classList.toggle('active', preset === matchedPreset);
|
|
2680
|
+
});
|
|
2681
|
+
if (presetState) {
|
|
2682
|
+
presetState.textContent = matchedPreset === 'custom'
|
|
2683
|
+
? 'Custom'
|
|
2684
|
+
: `Preset: ${matchedPreset}`;
|
|
2685
|
+
presetState.classList.toggle('custom', matchedPreset === 'custom');
|
|
2686
|
+
}
|
|
2687
|
+
if (toggle) {
|
|
2688
|
+
toggle.textContent = historyHintsVisible ? 'Hide' : 'Show';
|
|
2689
|
+
toggle.title = historyHintsVisible ? 'Hide keyboard shortcut hints' : 'Show keyboard shortcut hints';
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function updateHistoryFocusStatus() {
|
|
2694
|
+
const status = $('history-focus-status');
|
|
2695
|
+
const prevBtn = $('history-focus-prev') as HTMLButtonElement | null;
|
|
2696
|
+
const nextBtn = $('history-focus-next') as HTMLButtonElement | null;
|
|
2697
|
+
const list = $('history-list');
|
|
2698
|
+
if (!status || !list) return;
|
|
2699
|
+
const rows = Array.from(list.querySelectorAll('.history-item')) as HTMLElement[];
|
|
2700
|
+
if (rows.length === 0) {
|
|
2701
|
+
status.textContent = 'No row selected';
|
|
2702
|
+
status.classList.remove('active');
|
|
2703
|
+
if (prevBtn) prevBtn.disabled = true;
|
|
2704
|
+
if (nextBtn) nextBtn.disabled = true;
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
const boundedIndex = Math.max(0, Math.min(focusedHistoryIndex, rows.length - 1));
|
|
2709
|
+
const row = rows[boundedIndex];
|
|
2710
|
+
if (!row) {
|
|
2711
|
+
status.textContent = 'No row selected';
|
|
2712
|
+
status.classList.remove('active');
|
|
2713
|
+
if (prevBtn) prevBtn.disabled = true;
|
|
2714
|
+
if (nextBtn) nextBtn.disabled = true;
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
const processName = row.dataset.historyProcess || 'Unknown';
|
|
2719
|
+
const eventName = row.dataset.historyEvent || 'event';
|
|
2720
|
+
status.textContent = `${boundedIndex + 1}/${rows.length} · ${processName} · ${eventName}`;
|
|
2721
|
+
status.classList.add('active');
|
|
2722
|
+
if (prevBtn) prevBtn.disabled = boundedIndex <= 0;
|
|
2723
|
+
if (nextBtn) nextBtn.disabled = boundedIndex >= rows.length - 1;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
function getHistoryDetailKey(h: HistoryEntry) {
|
|
2727
|
+
return `${h.timestamp}::${h.process_name}::${h.event}::${h.pid || ''}`;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function syncDrawerToFocusedHistoryRow() {
|
|
2731
|
+
const list = $('history-list');
|
|
2732
|
+
if (!list) return;
|
|
2733
|
+
const rows = Array.from(list.querySelectorAll('.history-item')) as HTMLElement[];
|
|
2734
|
+
const row = rows[Math.max(0, Math.min(focusedHistoryIndex, rows.length - 1))];
|
|
2735
|
+
const processName = row?.dataset.historyProcess || '';
|
|
2736
|
+
if (!processName) return;
|
|
2737
|
+
if (historyFocusScope === 'inspect') {
|
|
2738
|
+
closeHistoryModal();
|
|
2739
|
+
openDrawer(processName);
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
if (drawerProcess === processName) return;
|
|
2743
|
+
openDrawer(processName);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
function applyHistoryAutoOpenPreference() {
|
|
2747
|
+
const toggle = $('history-auto-open-toggle') as HTMLInputElement | null;
|
|
2748
|
+
const scopeSelect = $('history-focus-scope-select') as HTMLSelectElement | null;
|
|
2749
|
+
if (toggle) toggle.checked = historyAutoOpen;
|
|
2750
|
+
if (scopeSelect) scopeSelect.value = historyFocusScope;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function updateHistoryClearButton() {
|
|
2754
|
+
const btn = $('history-clear-filters-btn') as HTMLButtonElement | null;
|
|
2755
|
+
const processFilter = $('history-process-filter') as HTMLSelectElement | null;
|
|
2756
|
+
const eventFilter = $('history-event-filter') as HTMLSelectElement | null;
|
|
2757
|
+
const metadataFilter = $('history-metadata-filter') as HTMLInputElement | null;
|
|
2758
|
+
if (!btn) return;
|
|
2759
|
+
|
|
2760
|
+
const active = !!(processFilter?.value || eventFilter?.value || metadataFilter?.value.trim());
|
|
2761
|
+
btn.disabled = !active;
|
|
2762
|
+
btn.style.opacity = active ? '' : '0.5';
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function focusHistoryRow(index: number, options?: { syncDrawer?: boolean }) {
|
|
2766
|
+
const list = $('history-list');
|
|
2767
|
+
if (!list) return;
|
|
2768
|
+
const rows = Array.from(list.querySelectorAll('.history-item')) as HTMLElement[];
|
|
2769
|
+
if (rows.length === 0) {
|
|
2770
|
+
focusedHistoryIndex = 0;
|
|
2771
|
+
focusedHistoryKey = null;
|
|
2772
|
+
updateHistoryFocusStatus();
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
focusedHistoryIndex = Math.max(0, Math.min(index, rows.length - 1));
|
|
2777
|
+
rows.forEach((row, rowIndex) => {
|
|
2778
|
+
const active = rowIndex === focusedHistoryIndex;
|
|
2779
|
+
row.classList.toggle('keyboard-focused', active);
|
|
2780
|
+
row.setAttribute('tabindex', active ? '0' : '-1');
|
|
2781
|
+
row.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
2782
|
+
if (active) {
|
|
2783
|
+
focusedHistoryKey = row.dataset.historyKey || null;
|
|
2784
|
+
}
|
|
2785
|
+
});
|
|
2786
|
+
|
|
2787
|
+
const activeRow = rows[focusedHistoryIndex];
|
|
2788
|
+
activeRow?.focus({ preventScroll: true });
|
|
2789
|
+
activeRow?.scrollIntoView({ block: 'nearest' });
|
|
2790
|
+
updateHistoryFocusStatus();
|
|
2791
|
+
if (options?.syncDrawer && historyAutoOpen) {
|
|
2792
|
+
syncDrawerToFocusedHistoryRow();
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function renderHistory() {
|
|
2797
|
+
const list = $('history-list');
|
|
2798
|
+
const processFilter = $('history-process-filter') as HTMLSelectElement;
|
|
2799
|
+
const eventFilter = $('history-event-filter') as HTMLSelectElement;
|
|
2800
|
+
const metadataFilter = $('history-metadata-filter') as HTMLInputElement;
|
|
2801
|
+
if (!list) return;
|
|
2802
|
+
|
|
2803
|
+
const processValue = processFilter?.value || '';
|
|
2804
|
+
const eventValue = eventFilter?.value || '';
|
|
2805
|
+
const metadataTerms = (metadataFilter?.value || '')
|
|
2806
|
+
.split(',')
|
|
2807
|
+
.map(v => v.toLowerCase().trim())
|
|
2808
|
+
.filter(Boolean);
|
|
2809
|
+
|
|
2810
|
+
let filtered = allHistory;
|
|
2811
|
+
if (processValue) {
|
|
2812
|
+
filtered = filtered.filter(h => h.process_name === processValue);
|
|
2813
|
+
}
|
|
2814
|
+
if (eventValue) {
|
|
2815
|
+
filtered = filtered.filter(h => h.event === eventValue);
|
|
2816
|
+
}
|
|
2817
|
+
if (metadataTerms.length > 0) {
|
|
2818
|
+
filtered = filtered.filter(h => {
|
|
2819
|
+
const details = formatHistoryDetails(h);
|
|
2820
|
+
return metadataTerms.every(term =>
|
|
2821
|
+
details.some(detail =>
|
|
2822
|
+
detail.label.toLowerCase().includes(term) ||
|
|
2823
|
+
detail.value.toLowerCase().includes(term)
|
|
2824
|
+
)
|
|
2825
|
+
);
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
updateHistoryClearButton();
|
|
2830
|
+
applyHistoryDensity();
|
|
2831
|
+
applyHistoryShortcutPreference();
|
|
2832
|
+
applyHistoryDetailsPreference();
|
|
2833
|
+
|
|
2834
|
+
if (filtered.length === 0) {
|
|
2835
|
+
list.innerHTML = '<div class="history-empty">No history found</div>';
|
|
2836
|
+
focusedHistoryIndex = 0;
|
|
2837
|
+
focusedHistoryKey = null;
|
|
2838
|
+
updateHistoryFocusStatus();
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
list.replaceChildren(...filtered.map((h, index) => {
|
|
2843
|
+
const time = new Date(h.timestamp);
|
|
2844
|
+
const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' ' + time.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
2845
|
+
const details = formatHistoryDetails(h);
|
|
2846
|
+
const detailKey = getHistoryDetailKey(h);
|
|
2847
|
+
const detailsOpen = historyDetailState.get(detailKey) ?? (historyDetailsDefault === 'expanded');
|
|
2848
|
+
return (
|
|
2849
|
+
<div className="history-item" data-history-process={h.process_name} data-history-event={h.event} data-history-key={detailKey} data-history-index={String(index)} tabIndex={-1} role="button" aria-selected="false">
|
|
2850
|
+
<span className="history-item-time">{timeStr}</span>
|
|
2851
|
+
<div className="history-item-main">
|
|
2852
|
+
<div className="history-item-meta">
|
|
2853
|
+
{historyShortcutsEnabled ? (
|
|
2854
|
+
<button
|
|
2855
|
+
className="history-item-process history-filter-shortcut"
|
|
2856
|
+
data-action="open-history-process"
|
|
2857
|
+
data-process={h.process_name}
|
|
2858
|
+
title={`Open process drawer for ${h.process_name}`}
|
|
2859
|
+
>
|
|
2860
|
+
{h.process_name}
|
|
2861
|
+
</button>
|
|
2862
|
+
) : (
|
|
2863
|
+
<span className="history-item-process history-static-label">{h.process_name}</span>
|
|
2864
|
+
)}
|
|
2865
|
+
{historyShortcutsEnabled ? (
|
|
2866
|
+
<button
|
|
2867
|
+
className={`history-item-event history-filter-shortcut ${h.event}`}
|
|
2868
|
+
data-action="filter-history-event"
|
|
2869
|
+
data-event={h.event}
|
|
2870
|
+
title={`Filter history to event ${h.event}`}
|
|
2871
|
+
>
|
|
2872
|
+
{h.event.replace('_', ' ')}
|
|
2873
|
+
</button>
|
|
2874
|
+
) : (
|
|
2875
|
+
<span className={`history-item-event history-static-label ${h.event}`}>
|
|
2876
|
+
{h.event.replace('_', ' ')}
|
|
2877
|
+
</span>
|
|
2878
|
+
)}
|
|
2879
|
+
{h.pid && <span className="history-item-pid">PID {h.pid}</span>}
|
|
2880
|
+
<details className="history-item-actions-menu">
|
|
2881
|
+
<summary className="history-item-actions-toggle">Actions</summary>
|
|
2882
|
+
<div className="history-item-actions">
|
|
2883
|
+
<button
|
|
2884
|
+
className="history-action-btn"
|
|
2885
|
+
data-action="open-history-process"
|
|
2886
|
+
data-process={h.process_name}
|
|
2887
|
+
title={`Open process drawer for ${h.process_name}`}
|
|
2888
|
+
>
|
|
2889
|
+
Open
|
|
2890
|
+
</button>
|
|
2891
|
+
<button
|
|
2892
|
+
className="history-action-btn"
|
|
2893
|
+
data-action="filter-history-process"
|
|
2894
|
+
data-process={h.process_name}
|
|
2895
|
+
title={`Filter history to process ${h.process_name}`}
|
|
2896
|
+
>
|
|
2897
|
+
Process Filter
|
|
2898
|
+
</button>
|
|
2899
|
+
<button
|
|
2900
|
+
className="history-action-btn"
|
|
2901
|
+
data-action="filter-history-event"
|
|
2902
|
+
data-event={h.event}
|
|
2903
|
+
title={`Filter history to event ${h.event}`}
|
|
2904
|
+
>
|
|
2905
|
+
Event Filter
|
|
2906
|
+
</button>
|
|
2907
|
+
</div>
|
|
2908
|
+
</details>
|
|
2909
|
+
</div>
|
|
2910
|
+
{details.length > 0 && (
|
|
2911
|
+
<details className="history-item-details-wrap" data-history-detail-key={detailKey} open={detailsOpen}>
|
|
2912
|
+
<summary className="history-item-details-summary">
|
|
2913
|
+
<span>Details</span>
|
|
2914
|
+
<span className="history-item-details-count">{details.length}</span>
|
|
2915
|
+
</summary>
|
|
2916
|
+
<div className="history-item-details">
|
|
2917
|
+
{details.map(detail => (
|
|
2918
|
+
<span className="history-item-detail">
|
|
2919
|
+
{historyShortcutsEnabled ? (
|
|
2920
|
+
<button
|
|
2921
|
+
className="history-item-detail-text history-item-filter-chip"
|
|
2922
|
+
data-action="filter-history-detail"
|
|
2923
|
+
data-filter={detail.value}
|
|
2924
|
+
title={`Filter history by ${detail.label}: ${detail.value}`}
|
|
2925
|
+
>
|
|
2926
|
+
{detail.label}: {detail.value}
|
|
2927
|
+
</button>
|
|
2928
|
+
) : (
|
|
2929
|
+
<span className="history-item-detail-text history-static-label">
|
|
2930
|
+
{detail.label}: {detail.value}
|
|
2931
|
+
</span>
|
|
2932
|
+
)}
|
|
2933
|
+
{detail.copyable && (
|
|
2934
|
+
<button
|
|
2935
|
+
className="history-item-copy"
|
|
2936
|
+
data-action="copy-history-detail"
|
|
2937
|
+
data-copy={detail.value}
|
|
2938
|
+
title={`Copy ${detail.label}`}
|
|
2939
|
+
>
|
|
2940
|
+
Copy
|
|
2941
|
+
</button>
|
|
2942
|
+
)}
|
|
2943
|
+
</span>
|
|
2944
|
+
) as unknown as Node)}
|
|
2945
|
+
</div>
|
|
2946
|
+
</details>
|
|
2947
|
+
)}
|
|
2948
|
+
</div>
|
|
2949
|
+
</div>
|
|
2950
|
+
) as unknown as Node;
|
|
2951
|
+
}));
|
|
2952
|
+
|
|
2953
|
+
if (pendingHistoryFocus) {
|
|
2954
|
+
const selector = `.history-item[data-history-process="${pendingHistoryFocus.process || ''}"][data-history-event="${pendingHistoryFocus.event || ''}"]`;
|
|
2955
|
+
const match = list.querySelector(selector) as HTMLElement | null;
|
|
2956
|
+
if (match) {
|
|
2957
|
+
list.querySelectorAll('.history-item.jump-highlight').forEach(el => el.classList.remove('jump-highlight'));
|
|
2958
|
+
match.classList.add('jump-highlight');
|
|
2959
|
+
match.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
2960
|
+
setTimeout(() => match.classList.remove('jump-highlight'), 2200);
|
|
2961
|
+
focusedHistoryIndex = Math.max(0, Array.from(list.querySelectorAll('.history-item')).indexOf(match));
|
|
2962
|
+
focusedHistoryKey = match.dataset.historyKey || null;
|
|
2963
|
+
pendingHistoryFocus = null;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
const rows = Array.from(list.querySelectorAll('.history-item')) as HTMLElement[];
|
|
2968
|
+
const restoredIndex = focusedHistoryKey
|
|
2969
|
+
? rows.findIndex(row => row.dataset.historyKey === focusedHistoryKey)
|
|
2970
|
+
: -1;
|
|
2971
|
+
if (restoredIndex >= 0) {
|
|
2972
|
+
focusedHistoryIndex = restoredIndex;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
focusHistoryRow(focusedHistoryIndex);
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
async function openHistoryModalWithFilters(filters?: { process?: string; event?: string; metadata?: string; focus?: { process?: string; event?: string } }) {
|
|
2979
|
+
const modal = $('history-modal');
|
|
2980
|
+
const processFilter = $('history-process-filter') as HTMLSelectElement | null;
|
|
2981
|
+
const eventFilter = $('history-event-filter') as HTMLSelectElement | null;
|
|
2982
|
+
const metadataFilter = $('history-metadata-filter') as HTMLInputElement | null;
|
|
2983
|
+
|
|
2984
|
+
if (modal) modal.classList.add('active');
|
|
2985
|
+
await loadHistory();
|
|
2986
|
+
|
|
2987
|
+
if (processFilter) processFilter.value = filters?.process || '';
|
|
2988
|
+
if (eventFilter) eventFilter.value = filters?.event || '';
|
|
2989
|
+
if (metadataFilter) metadataFilter.value = filters?.metadata || '';
|
|
2990
|
+
setHistoryFocusTarget(filters?.focus || pendingHistoryFocus || null);
|
|
2991
|
+
|
|
2992
|
+
updateHistoryClearButton();
|
|
2993
|
+
applyHistoryAutoOpenPreference();
|
|
2994
|
+
applyHistoryHintsPreference();
|
|
2995
|
+
renderHistory();
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
function openHistoryModal() {
|
|
2999
|
+
openHistoryModalWithFilters();
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function closeHistoryModal() {
|
|
3003
|
+
const modal = $('history-modal');
|
|
3004
|
+
if (modal) modal.classList.remove('active');
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
$('history-btn')?.addEventListener('click', openHistoryModal);
|
|
3008
|
+
$('history-modal-close')?.addEventListener('click', closeHistoryModal);
|
|
3009
|
+
$('history-modal')?.addEventListener('click', (e) => {
|
|
3010
|
+
if ((e.target as Element).classList.contains('modal-overlay')) {
|
|
3011
|
+
closeHistoryModal();
|
|
3012
|
+
}
|
|
3013
|
+
});
|
|
3014
|
+
$('history-process-filter')?.addEventListener('change', renderHistory);
|
|
3015
|
+
$('history-event-filter')?.addEventListener('change', renderHistory);
|
|
3016
|
+
$('history-metadata-filter')?.addEventListener('input', renderHistory);
|
|
3017
|
+
$('history-density-select')?.addEventListener('change', () => {
|
|
3018
|
+
const select = $('history-density-select') as HTMLSelectElement | null;
|
|
3019
|
+
historyDensity = select?.value === 'compact' ? 'compact' : 'cozy';
|
|
3020
|
+
localStorage.setItem('bgr_history_density', historyDensity);
|
|
3021
|
+
applyHistoryDensity();
|
|
3022
|
+
renderHistory();
|
|
3023
|
+
showToast(`History density set to ${historyDensity}`, 'success');
|
|
3024
|
+
});
|
|
3025
|
+
$('history-shortcuts-toggle')?.addEventListener('change', () => {
|
|
3026
|
+
const toggle = $('history-shortcuts-toggle') as HTMLInputElement | null;
|
|
3027
|
+
historyShortcutsEnabled = !!toggle?.checked;
|
|
3028
|
+
localStorage.setItem('bgr_history_shortcuts', String(historyShortcutsEnabled));
|
|
3029
|
+
applyHistoryShortcutPreference();
|
|
3030
|
+
renderHistory();
|
|
3031
|
+
showToast(`History shortcuts ${historyShortcutsEnabled ? 'enabled' : 'hidden'}`, 'success');
|
|
3032
|
+
});
|
|
3033
|
+
$('history-details-default-select')?.addEventListener('change', () => {
|
|
3034
|
+
const select = $('history-details-default-select') as HTMLSelectElement | null;
|
|
3035
|
+
historyDetailsDefault = select?.value === 'expanded' ? 'expanded' : 'collapsed';
|
|
3036
|
+
localStorage.setItem('bgr_history_details_default', historyDetailsDefault);
|
|
3037
|
+
applyHistoryDetailsPreference();
|
|
3038
|
+
renderHistory();
|
|
3039
|
+
showToast(`History details default set to ${historyDetailsDefault}`, 'success');
|
|
3040
|
+
});
|
|
3041
|
+
$('history-auto-open-toggle')?.addEventListener('change', () => {
|
|
3042
|
+
const toggle = $('history-auto-open-toggle') as HTMLInputElement | null;
|
|
3043
|
+
historyAutoOpen = !!toggle?.checked;
|
|
3044
|
+
localStorage.setItem('bgr_history_auto_open', String(historyAutoOpen));
|
|
3045
|
+
applyHistoryAutoOpenPreference();
|
|
3046
|
+
showToast(`History auto-open ${historyAutoOpen ? 'enabled' : 'disabled'}`, 'success');
|
|
3047
|
+
});
|
|
3048
|
+
$('history-focus-scope-select')?.addEventListener('change', () => {
|
|
3049
|
+
const select = $('history-focus-scope-select') as HTMLSelectElement | null;
|
|
3050
|
+
historyFocusScope = select?.value === 'inspect' ? 'inspect' : 'sync';
|
|
3051
|
+
localStorage.setItem('bgr_history_focus_scope', historyFocusScope);
|
|
3052
|
+
applyHistoryAutoOpenPreference();
|
|
3053
|
+
showToast(`History focus scope set to ${historyFocusScope}`, 'success');
|
|
3054
|
+
});
|
|
3055
|
+
$('history-focus-prev')?.addEventListener('click', () => {
|
|
3056
|
+
focusHistoryRow(focusedHistoryIndex - 1, { syncDrawer: true });
|
|
3057
|
+
});
|
|
3058
|
+
$('history-focus-next')?.addEventListener('click', () => {
|
|
3059
|
+
focusHistoryRow(focusedHistoryIndex + 1, { syncDrawer: true });
|
|
3060
|
+
});
|
|
3061
|
+
['nav', 'open', 'filter', 'details', 'close'].forEach(group => {
|
|
3062
|
+
$(`history-hint-group-${group}`)?.addEventListener('change', () => {
|
|
3063
|
+
const input = $(`history-hint-group-${group}`) as HTMLInputElement | null;
|
|
3064
|
+
(historyHintGroups as Record<string, boolean>)[group] = !!input?.checked;
|
|
3065
|
+
localStorage.setItem('bgr_history_hint_groups', JSON.stringify(historyHintGroups));
|
|
3066
|
+
applyHistoryHintsPreference();
|
|
3067
|
+
showToast(`History hint group ${group} ${input?.checked ? 'shown' : 'hidden'}`, 'success');
|
|
3068
|
+
});
|
|
3069
|
+
});
|
|
3070
|
+
document.querySelectorAll('[data-history-hint-preset]').forEach(el => {
|
|
3071
|
+
el.addEventListener('click', () => {
|
|
3072
|
+
const preset = (el as HTMLElement).dataset.historyHintPreset as 'minimal' | 'navigation' | 'all' | undefined;
|
|
3073
|
+
if (!preset) return;
|
|
3074
|
+
setHistoryHintPreset(preset);
|
|
3075
|
+
showToast(`History hint preset set to ${preset}`, 'success');
|
|
3076
|
+
});
|
|
3077
|
+
});
|
|
3078
|
+
$('history-hint-density-select')?.addEventListener('change', () => {
|
|
3079
|
+
const select = $('history-hint-density-select') as HTMLSelectElement | null;
|
|
3080
|
+
historyHintDensity = select?.value === 'compact' ? 'compact' : 'full';
|
|
3081
|
+
localStorage.setItem('bgr_history_hint_density', historyHintDensity);
|
|
3082
|
+
applyHistoryHintsPreference();
|
|
3083
|
+
showToast(`History hint density set to ${historyHintDensity}`, 'success');
|
|
3084
|
+
});
|
|
3085
|
+
$('history-hints-toggle')?.addEventListener('click', () => {
|
|
3086
|
+
historyHintsVisible = !historyHintsVisible;
|
|
3087
|
+
localStorage.setItem('bgr_history_hints_visible', String(historyHintsVisible));
|
|
3088
|
+
applyHistoryHintsPreference();
|
|
3089
|
+
showToast(`History hints ${historyHintsVisible ? 'shown' : 'hidden'}`, 'success');
|
|
3090
|
+
});
|
|
3091
|
+
$('history-clear-filters-btn')?.addEventListener('click', () => {
|
|
3092
|
+
const processFilter = $('history-process-filter') as HTMLSelectElement | null;
|
|
3093
|
+
const eventFilter = $('history-event-filter') as HTMLSelectElement | null;
|
|
3094
|
+
const metadataFilter = $('history-metadata-filter') as HTMLInputElement | null;
|
|
3095
|
+
if (processFilter) processFilter.value = '';
|
|
3096
|
+
if (eventFilter) eventFilter.value = '';
|
|
3097
|
+
if (metadataFilter) metadataFilter.value = '';
|
|
3098
|
+
setHistoryFocusTarget(null);
|
|
3099
|
+
renderHistory();
|
|
3100
|
+
showToast('History filters cleared', 'success');
|
|
3101
|
+
});
|
|
3102
|
+
$('history-list')?.addEventListener('toggle', (e) => {
|
|
3103
|
+
const target = e.target as HTMLElement;
|
|
3104
|
+
if (!(target instanceof HTMLDetailsElement) || !target.classList.contains('history-item-details-wrap')) return;
|
|
3105
|
+
const key = target.dataset.historyDetailKey;
|
|
3106
|
+
if (!key) return;
|
|
3107
|
+
historyDetailState.set(key, target.open);
|
|
3108
|
+
}, true);
|
|
3109
|
+
$('history-list')?.addEventListener('click', async (e) => {
|
|
3110
|
+
const target = e.target as Element;
|
|
3111
|
+
const row = target.closest('.history-item') as HTMLElement | null;
|
|
3112
|
+
if (row && row.dataset.historyIndex) {
|
|
3113
|
+
focusHistoryRow(parseInt(row.dataset.historyIndex, 10) || 0);
|
|
3114
|
+
}
|
|
3115
|
+
const copyBtn = target.closest('[data-action="copy-history-detail"]') as HTMLElement | null;
|
|
3116
|
+
if (copyBtn) {
|
|
3117
|
+
const value = copyBtn.dataset.copy;
|
|
3118
|
+
if (!value) return;
|
|
3119
|
+
try {
|
|
3120
|
+
await navigator.clipboard.writeText(value);
|
|
3121
|
+
showToast('Copied to clipboard', 'success');
|
|
3122
|
+
} catch {
|
|
3123
|
+
showToast('Failed to copy', 'error');
|
|
3124
|
+
}
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
const openProcessBtn = target.closest('[data-action="open-history-process"]') as HTMLElement | null;
|
|
3129
|
+
if (openProcessBtn) {
|
|
3130
|
+
const value = openProcessBtn.dataset.process || '';
|
|
3131
|
+
closeHistoryModal();
|
|
3132
|
+
openDrawer(value);
|
|
3133
|
+
showToast(`Opened process "${value}"`, 'info');
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
const processBtn = target.closest('[data-action="filter-history-process"]') as HTMLElement | null;
|
|
3138
|
+
if (processBtn) {
|
|
3139
|
+
const value = processBtn.dataset.process || '';
|
|
3140
|
+
const select = $('history-process-filter') as HTMLSelectElement | null;
|
|
3141
|
+
if (!select) return;
|
|
3142
|
+
select.value = value;
|
|
3143
|
+
renderHistory();
|
|
3144
|
+
showToast(`Filtering history to process "${value}"`, 'info');
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
const eventBtn = target.closest('[data-action="filter-history-event"]') as HTMLElement | null;
|
|
3149
|
+
if (eventBtn) {
|
|
3150
|
+
const value = eventBtn.dataset.event || '';
|
|
3151
|
+
const select = $('history-event-filter') as HTMLSelectElement | null;
|
|
3152
|
+
if (!select) return;
|
|
3153
|
+
select.value = value;
|
|
3154
|
+
renderHistory();
|
|
3155
|
+
showToast(`Filtering history to event "${value}"`, 'info');
|
|
3156
|
+
return;
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
const filterBtn = target.closest('[data-action="filter-history-detail"]') as HTMLElement | null;
|
|
3160
|
+
if (filterBtn) {
|
|
3161
|
+
const value = filterBtn.dataset.filter || '';
|
|
3162
|
+
const input = $('history-metadata-filter') as HTMLInputElement | null;
|
|
3163
|
+
if (!input) return;
|
|
3164
|
+
const existing = input.value
|
|
3165
|
+
.split(',')
|
|
3166
|
+
.map(v => v.trim())
|
|
3167
|
+
.filter(Boolean);
|
|
3168
|
+
if (!existing.some(v => v.toLowerCase() === value.toLowerCase())) {
|
|
3169
|
+
existing.push(value);
|
|
3170
|
+
}
|
|
3171
|
+
input.value = existing.join(', ');
|
|
3172
|
+
renderHistory();
|
|
3173
|
+
showToast(`Added history filter "${value}"`, 'info');
|
|
3174
|
+
}
|
|
3175
|
+
});
|
|
3176
|
+
|
|
3177
|
+
// ─── Deploy Results Modal ───
|
|
3178
|
+
|
|
3179
|
+
function renderDeployResults(summary?: { group?: string | null; deployed?: number; skipped?: number; failed?: number; total?: number }) {
|
|
3180
|
+
const summaryEl = $('deploy-results-summary');
|
|
3181
|
+
const listEl = $('deploy-results-list');
|
|
3182
|
+
if (!summaryEl || !listEl) return;
|
|
3183
|
+
|
|
3184
|
+
latestDeploySummary = summary || latestDeploySummary;
|
|
3185
|
+
|
|
3186
|
+
if (latestDeploySummary) {
|
|
3187
|
+
const deployed = latestDeployResults.filter(r => r.ok).length;
|
|
3188
|
+
const skipped = latestDeployResults.filter(r => !r.ok && r.skipped && r.phase === 'done').length;
|
|
3189
|
+
const failed = latestDeployResults.filter(r => !r.ok && !r.skipped && r.phase === 'done').length;
|
|
3190
|
+
const running = latestDeployResults.filter(r => r.phase === 'running').length;
|
|
3191
|
+
const pending = latestDeployResults.filter(r => r.phase === 'pending').length;
|
|
3192
|
+
const scope = latestDeploySummary.group ? `Group: ${latestDeploySummary.group}` : 'All deployable processes';
|
|
3193
|
+
summaryEl.innerHTML = [
|
|
3194
|
+
`<span><strong>${scope}</strong></span>`,
|
|
3195
|
+
`<span>mode ${deployConcurrency}×</span>`,
|
|
3196
|
+
`<span>${deployed} deployed</span>`,
|
|
3197
|
+
`<span>${skipped} skipped</span>`,
|
|
3198
|
+
`<span>${failed} failed</span>`,
|
|
3199
|
+
`<span>${running} running</span>`,
|
|
3200
|
+
`<span>${pending} pending</span>`,
|
|
3201
|
+
`<span>${latestDeployResults.length || latestDeploySummary.total || 0} total</span>`,
|
|
3202
|
+
].join('');
|
|
3203
|
+
} else {
|
|
3204
|
+
summaryEl.textContent = 'No deploy results yet';
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
if (latestDeployResults.length === 0) {
|
|
3208
|
+
listEl.innerHTML = '<div class="history-empty">Run a bulk deploy to see detailed results</div>';
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
listEl.replaceChildren(...latestDeployResults.map(result => {
|
|
3213
|
+
const statusClass = result.phase === 'running'
|
|
3214
|
+
? 'running'
|
|
3215
|
+
: result.phase === 'pending'
|
|
3216
|
+
? 'pending'
|
|
3217
|
+
: result.ok
|
|
3218
|
+
? 'ok'
|
|
3219
|
+
: result.skipped
|
|
3220
|
+
? 'skipped'
|
|
3221
|
+
: 'failed';
|
|
3222
|
+
const statusLabel = result.phase === 'running'
|
|
3223
|
+
? 'Deploying…'
|
|
3224
|
+
: result.phase === 'pending'
|
|
3225
|
+
? 'Pending'
|
|
3226
|
+
: result.ok
|
|
3227
|
+
? 'Deployed'
|
|
3228
|
+
: result.skipped
|
|
3229
|
+
? 'Skipped'
|
|
3230
|
+
: 'Failed';
|
|
3231
|
+
const details = [result.reason, result.pullOutput, result.installOutput].filter(Boolean).join('\n\n');
|
|
3232
|
+
|
|
3233
|
+
return (
|
|
3234
|
+
<div className={`deploy-result-item ${statusClass}`}>
|
|
3235
|
+
<div className="deploy-result-head">
|
|
3236
|
+
<span className="deploy-result-name">{result.name}</span>
|
|
3237
|
+
<div className="deploy-result-head-right">
|
|
3238
|
+
<span className={`deploy-result-status ${statusClass}`}>{statusLabel}</span>
|
|
3239
|
+
{!result.ok && result.phase !== 'pending' && result.phase !== 'running' && (
|
|
3240
|
+
<button className="btn btn-ghost btn-sm deploy-retry-btn" data-action="deploy-retry" data-name={result.name} disabled={result.retrying ? true : undefined}>
|
|
3241
|
+
{result.retrying ? 'Retrying…' : 'Retry'}
|
|
3242
|
+
</button>
|
|
3243
|
+
)}
|
|
3244
|
+
</div>
|
|
3245
|
+
</div>
|
|
3246
|
+
<div className="deploy-result-meta">
|
|
3247
|
+
<span><strong>Package manager:</strong> {result.packageManager || 'none'}</span>
|
|
3248
|
+
<span><strong>Install step:</strong> {result.installAttempted ? (result.installCommand || 'attempted') : 'skipped'}</span>
|
|
3249
|
+
<button className="btn btn-ghost btn-sm deploy-history-btn" data-action="deploy-history" data-name={result.name} title={`Open History for ${result.name}`}>
|
|
3250
|
+
History
|
|
3251
|
+
</button>
|
|
3252
|
+
</div>
|
|
3253
|
+
{result.reason && <div className="deploy-result-reason">{result.reason}</div>}
|
|
3254
|
+
{(result.pullOutput || result.installOutput) && (
|
|
3255
|
+
<details className="deploy-result-details">
|
|
3256
|
+
<summary>Output</summary>
|
|
3257
|
+
<pre>{details}</pre>
|
|
3258
|
+
</details>
|
|
3259
|
+
)}
|
|
3260
|
+
</div>
|
|
3261
|
+
) as unknown as Node;
|
|
3262
|
+
}));
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function openDeployResultsModal() {
|
|
3266
|
+
const modal = $('deploy-results-modal');
|
|
3267
|
+
if (modal) modal.classList.add('active');
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
function closeDeployResultsModal() {
|
|
3271
|
+
const modal = $('deploy-results-modal');
|
|
3272
|
+
if (modal) modal.classList.remove('active');
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
async function retryDeployResult(name: string) {
|
|
3276
|
+
const index = latestDeployResults.findIndex(r => r.name === name);
|
|
3277
|
+
if (index === -1) return;
|
|
3278
|
+
|
|
3279
|
+
latestDeployResults[index] = { ...latestDeployResults[index], retrying: true, phase: 'running' };
|
|
3280
|
+
renderDeployResults();
|
|
3281
|
+
|
|
3282
|
+
try {
|
|
3283
|
+
const res = await fetch(`/api/deploy/${encodeURIComponent(name)}`, { method: 'POST' });
|
|
3284
|
+
const data = await res.json();
|
|
3285
|
+
latestDeployResults[index] = {
|
|
3286
|
+
name,
|
|
3287
|
+
ok: !!res.ok,
|
|
3288
|
+
skipped: data.skipped,
|
|
3289
|
+
reason: res.ok ? undefined : (data.error || data.reason || `Failed to deploy '${name}'`),
|
|
3290
|
+
pullOutput: data.pullOutput || '',
|
|
3291
|
+
installOutput: data.installOutput || '',
|
|
3292
|
+
packageManager: data.packageManager || null,
|
|
3293
|
+
installCommand: data.installCommand || '',
|
|
3294
|
+
installAttempted: !!data.installAttempted,
|
|
3295
|
+
retrying: false,
|
|
3296
|
+
phase: 'done',
|
|
3297
|
+
};
|
|
3298
|
+
showToast(res.ok ? `Deployed "${name}" successfully` : `Retry failed for "${name}"`, res.ok ? 'success' : 'error');
|
|
3299
|
+
} catch {
|
|
3300
|
+
latestDeployResults[index] = {
|
|
3301
|
+
...latestDeployResults[index],
|
|
3302
|
+
ok: false,
|
|
3303
|
+
skipped: false,
|
|
3304
|
+
reason: `Failed to deploy '${name}'`,
|
|
3305
|
+
retrying: false,
|
|
3306
|
+
phase: 'done',
|
|
3307
|
+
};
|
|
3308
|
+
showToast(`Retry failed for "${name}"`, 'error');
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
renderDeployResults();
|
|
3312
|
+
await loadProcessesFresh();
|
|
3313
|
+
mutationUntil = Date.now() + 5000;
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
$('deploy-results-modal-close')?.addEventListener('click', closeDeployResultsModal);
|
|
3317
|
+
$('deploy-results-modal')?.addEventListener('click', (e) => {
|
|
3318
|
+
if ((e.target as Element).classList.contains('modal-overlay')) {
|
|
3319
|
+
closeDeployResultsModal();
|
|
3320
|
+
}
|
|
3321
|
+
});
|
|
3322
|
+
$('deploy-results-list')?.addEventListener('click', (e) => {
|
|
3323
|
+
const target = e.target as Element;
|
|
3324
|
+
const historyBtn = target.closest('[data-action="deploy-history"]') as HTMLElement | null;
|
|
3325
|
+
if (historyBtn) {
|
|
3326
|
+
const name = historyBtn.dataset.name;
|
|
3327
|
+
if (!name) return;
|
|
3328
|
+
closeDeployResultsModal();
|
|
3329
|
+
openHistoryModalWithFilters({ process: name, event: 'deploy', focus: { process: name, event: 'deploy' } });
|
|
3330
|
+
showToast(`Opened History for "${name}"`, 'info');
|
|
3331
|
+
return;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
const btn = target.closest('[data-action="deploy-retry"]') as HTMLElement | null;
|
|
3335
|
+
const name = btn?.dataset.name;
|
|
3336
|
+
if (!name || btn?.hasAttribute('disabled')) return;
|
|
3337
|
+
retryDeployResult(name);
|
|
3338
|
+
});
|
|
3339
|
+
|
|
1611
3340
|
// ─── Toolbar Actions ───
|
|
1612
3341
|
$('refresh-btn')?.addEventListener('click', () => {
|
|
1613
3342
|
loadProcesses();
|
|
@@ -1657,6 +3386,107 @@ export default function mount(): () => void {
|
|
|
1657
3386
|
mutationUntil = Date.now() + 3000;
|
|
1658
3387
|
});
|
|
1659
3388
|
|
|
3389
|
+
// ─── Deploy All Button ───
|
|
3390
|
+
$('deploy-all-btn')?.addEventListener('click', async () => {
|
|
3391
|
+
const deployAllBtn = $('deploy-all-btn') as HTMLButtonElement;
|
|
3392
|
+
if (!deployAllBtn || deployAllBtn.disabled) return;
|
|
3393
|
+
|
|
3394
|
+
const targets = allProcesses.filter(p => {
|
|
3395
|
+
if (p.name === 'bgr-dashboard' || p.name === 'bgr-guard') return false;
|
|
3396
|
+
if (groupQuery && p.group !== groupQuery) return false;
|
|
3397
|
+
return true;
|
|
3398
|
+
});
|
|
3399
|
+
if (targets.length === 0) return;
|
|
3400
|
+
|
|
3401
|
+
const scope = groupQuery ? `group "${groupQuery}"` : 'all deployable processes';
|
|
3402
|
+
const concurrency = Math.max(1, Math.min(4, deployConcurrency));
|
|
3403
|
+
deployAllBtn.disabled = true;
|
|
3404
|
+
deployAllBtn.style.opacity = '0.5';
|
|
3405
|
+
if (deployConcurrencySelect) deployConcurrencySelect.disabled = true;
|
|
3406
|
+
showToast(`Deploying ${scope} (${concurrency}×)...`, 'info');
|
|
3407
|
+
|
|
3408
|
+
latestDeploySummary = { group: groupQuery || null, total: targets.length };
|
|
3409
|
+
latestDeployResults = targets.map(p => ({
|
|
3410
|
+
name: p.name,
|
|
3411
|
+
ok: false,
|
|
3412
|
+
skipped: false,
|
|
3413
|
+
reason: '',
|
|
3414
|
+
pullOutput: '',
|
|
3415
|
+
installOutput: '',
|
|
3416
|
+
packageManager: null,
|
|
3417
|
+
installCommand: '',
|
|
3418
|
+
installAttempted: false,
|
|
3419
|
+
phase: 'pending',
|
|
3420
|
+
}));
|
|
3421
|
+
renderDeployResults(latestDeploySummary);
|
|
3422
|
+
openDeployResultsModal();
|
|
3423
|
+
|
|
3424
|
+
try {
|
|
3425
|
+
async function runDeployAtIndex(i: number) {
|
|
3426
|
+
const target = latestDeployResults[i];
|
|
3427
|
+
latestDeployResults[i] = { ...target, phase: 'running' };
|
|
3428
|
+
renderDeployResults();
|
|
3429
|
+
|
|
3430
|
+
try {
|
|
3431
|
+
const res = await fetch(`/api/deploy/${encodeURIComponent(target.name)}`, { method: 'POST' });
|
|
3432
|
+
const data = await res.json();
|
|
3433
|
+
latestDeployResults[i] = {
|
|
3434
|
+
name: target.name,
|
|
3435
|
+
ok: !!res.ok,
|
|
3436
|
+
skipped: data.skipped,
|
|
3437
|
+
reason: res.ok ? undefined : (data.error || data.reason || `Failed to deploy '${target.name}'`),
|
|
3438
|
+
pullOutput: data.pullOutput || '',
|
|
3439
|
+
installOutput: data.installOutput || '',
|
|
3440
|
+
packageManager: data.packageManager || null,
|
|
3441
|
+
installCommand: data.installCommand || '',
|
|
3442
|
+
installAttempted: !!data.installAttempted,
|
|
3443
|
+
phase: 'done',
|
|
3444
|
+
};
|
|
3445
|
+
} catch {
|
|
3446
|
+
latestDeployResults[i] = {
|
|
3447
|
+
name: target.name,
|
|
3448
|
+
ok: false,
|
|
3449
|
+
skipped: false,
|
|
3450
|
+
reason: `Failed to deploy '${target.name}'`,
|
|
3451
|
+
pullOutput: '',
|
|
3452
|
+
installOutput: '',
|
|
3453
|
+
packageManager: null,
|
|
3454
|
+
installCommand: '',
|
|
3455
|
+
installAttempted: false,
|
|
3456
|
+
phase: 'done',
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
renderDeployResults();
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
let cursor = 0;
|
|
3463
|
+
const workers = Array.from({ length: Math.min(concurrency, latestDeployResults.length) }, async () => {
|
|
3464
|
+
while (cursor < latestDeployResults.length) {
|
|
3465
|
+
const current = cursor++;
|
|
3466
|
+
await runDeployAtIndex(current);
|
|
3467
|
+
}
|
|
3468
|
+
});
|
|
3469
|
+
await Promise.all(workers);
|
|
3470
|
+
|
|
3471
|
+
const deployed = latestDeployResults.filter(r => r.ok).length;
|
|
3472
|
+
const skipped = latestDeployResults.filter(r => !r.ok && r.skipped).length;
|
|
3473
|
+
const failed = latestDeployResults.filter(r => !r.ok && !r.skipped).length;
|
|
3474
|
+
const parts = [];
|
|
3475
|
+
if (deployed) parts.push(`${deployed} deployed`);
|
|
3476
|
+
if (skipped) parts.push(`${skipped} skipped`);
|
|
3477
|
+
if (failed) parts.push(`${failed} failed`);
|
|
3478
|
+
showToast(parts.length > 0 ? `Deploy complete: ${parts.join(', ')}` : 'Deploy complete', failed > 0 ? 'error' : 'success');
|
|
3479
|
+
} catch {
|
|
3480
|
+
showToast('Failed to deploy processes', 'error');
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
deployAllBtn.disabled = false;
|
|
3484
|
+
deployAllBtn.style.opacity = '';
|
|
3485
|
+
if (deployConcurrencySelect) deployConcurrencySelect.disabled = false;
|
|
3486
|
+
await loadProcessesFresh();
|
|
3487
|
+
mutationUntil = Date.now() + 5000;
|
|
3488
|
+
});
|
|
3489
|
+
|
|
1660
3490
|
// Group toggle removed — always-on directory grouping
|
|
1661
3491
|
|
|
1662
3492
|
// ─── Keyboard Shortcuts ───
|
|
@@ -1734,6 +3564,66 @@ export default function mount(): () => void {
|
|
|
1734
3564
|
function handleKeydown(e: KeyboardEvent) {
|
|
1735
3565
|
// Skip all shortcuts when inside text inputs or textareas
|
|
1736
3566
|
const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
|
|
3567
|
+
const historyModal = $('history-modal');
|
|
3568
|
+
const historyList = $('history-list');
|
|
3569
|
+
const historyOpen = !!historyModal?.classList.contains('active');
|
|
3570
|
+
|
|
3571
|
+
if (historyOpen && !inInput) {
|
|
3572
|
+
const rows = Array.from(historyList?.querySelectorAll('.history-item') || []) as HTMLElement[];
|
|
3573
|
+
const activeRow = rows[focusedHistoryIndex] || null;
|
|
3574
|
+
if (e.key === 'ArrowDown' || e.key === 'j') {
|
|
3575
|
+
e.preventDefault();
|
|
3576
|
+
focusHistoryRow(focusedHistoryIndex + 1, { syncDrawer: true });
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
if (e.key === 'ArrowUp' || e.key === 'k') {
|
|
3580
|
+
e.preventDefault();
|
|
3581
|
+
focusHistoryRow(focusedHistoryIndex - 1, { syncDrawer: true });
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
if ((e.key === 'Enter' || e.key === 'o' || e.key === 'O') && activeRow) {
|
|
3585
|
+
e.preventDefault();
|
|
3586
|
+
const processName = activeRow.dataset.historyProcess || '';
|
|
3587
|
+
if (processName) {
|
|
3588
|
+
closeHistoryModal();
|
|
3589
|
+
openDrawer(processName);
|
|
3590
|
+
showToast(`Opened process "${processName}"`, 'info');
|
|
3591
|
+
}
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
if ((e.key === 'f' || e.key === 'F') && activeRow) {
|
|
3595
|
+
e.preventDefault();
|
|
3596
|
+
const processName = activeRow.dataset.historyProcess || '';
|
|
3597
|
+
const select = $('history-process-filter') as HTMLSelectElement | null;
|
|
3598
|
+
if (select && processName) {
|
|
3599
|
+
select.value = processName;
|
|
3600
|
+
renderHistory();
|
|
3601
|
+
showToast(`Filtering history to process "${processName}"`, 'info');
|
|
3602
|
+
}
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
if ((e.key === 'e' || e.key === 'E') && activeRow) {
|
|
3606
|
+
e.preventDefault();
|
|
3607
|
+
const eventName = activeRow.dataset.historyEvent || '';
|
|
3608
|
+
const select = $('history-event-filter') as HTMLSelectElement | null;
|
|
3609
|
+
if (select && eventName) {
|
|
3610
|
+
select.value = eventName;
|
|
3611
|
+
renderHistory();
|
|
3612
|
+
showToast(`Filtering history to event "${eventName}"`, 'info');
|
|
3613
|
+
}
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
if ((e.key === ' ' || e.key === 'Spacebar') && activeRow) {
|
|
3617
|
+
e.preventDefault();
|
|
3618
|
+
const details = activeRow.querySelector('.history-item-details-wrap') as HTMLDetailsElement | null;
|
|
3619
|
+
if (details) {
|
|
3620
|
+
details.open = !details.open;
|
|
3621
|
+
const key = details.dataset.historyDetailKey;
|
|
3622
|
+
if (key) historyDetailState.set(key, details.open);
|
|
3623
|
+
}
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
1737
3627
|
|
|
1738
3628
|
// "/" to focus search (unless already in an input)
|
|
1739
3629
|
if (e.key === '/' && !inInput) {
|
|
@@ -1753,6 +3643,10 @@ export default function mount(): () => void {
|
|
|
1753
3643
|
closeContextMenu();
|
|
1754
3644
|
return;
|
|
1755
3645
|
}
|
|
3646
|
+
if (historyOpen) {
|
|
3647
|
+
closeHistoryModal();
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
1756
3650
|
if (drawer?.classList.contains('open')) {
|
|
1757
3651
|
closeDrawer();
|
|
1758
3652
|
} else {
|
|
@@ -1803,6 +3697,17 @@ export default function mount(): () => void {
|
|
|
1803
3697
|
return;
|
|
1804
3698
|
}
|
|
1805
3699
|
|
|
3700
|
+
// T — cycle stat filter
|
|
3701
|
+
if (e.key === 't' || e.key === 'T') {
|
|
3702
|
+
e.preventDefault();
|
|
3703
|
+
const cycle: Array<typeof statusFilter> = ['all', 'running', 'stopped', 'guarded'];
|
|
3704
|
+
const idx = cycle.indexOf(statusFilter);
|
|
3705
|
+
setStatusFilter(cycle[(idx + 1) % cycle.length]);
|
|
3706
|
+
const labels: Record<string, string> = { all: 'all processes', running: 'running', stopped: 'stopped', guarded: 'guarded' };
|
|
3707
|
+
showToast(`Showing ${labels[statusFilter]}`, 'info');
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
1806
3711
|
// Process actions — require a focused row
|
|
1807
3712
|
if (!focusedProcessName) return;
|
|
1808
3713
|
|