bgrun 3.12.1 → 3.12.3
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/next-port/route.ts +32 -0
- package/dashboard/app/api/start/route.ts +6 -0
- package/dashboard/app/globals.css +1313 -108
- package/dashboard/app/page.client.tsx +1576 -8
- package/dashboard/app/page.tsx +194 -5
- package/dist/index.js +143 -25
- package/package.json +1 -1
- package/src/bgrun.test.ts +204 -0
- package/src/db.ts +142 -0
- package/src/deploy.ts +163 -0
- package/src/index.ts +9 -0
- package/src/platform.ts +29 -14
|
@@ -409,12 +409,34 @@ function showToast(message: string, type: 'success' | 'error' | 'info' = 'info')
|
|
|
409
409
|
|
|
410
410
|
export default function mount(): () => void {
|
|
411
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
|
+
|
|
412
427
|
let selectedProcess: string | null = null;
|
|
413
428
|
let isFetching = false;
|
|
414
429
|
let isFirstLoad = true;
|
|
415
430
|
let allProcesses: ProcessData[] = [];
|
|
416
431
|
let searchQuery = '';
|
|
417
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));
|
|
418
440
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
419
441
|
let collapsedGroups: Set<string> = new Set(JSON.parse(localStorage.getItem('bgr_collapsed_groups') || '[]'));
|
|
420
442
|
let drawerProcess: string | null = null;
|
|
@@ -423,6 +445,30 @@ export default function mount(): () => void {
|
|
|
423
445
|
let mutationUntil = 0; // Timestamp: ignore SSE updates until this time (after mutations)
|
|
424
446
|
let configSubtab = 'toml'; // 'toml' | 'env'
|
|
425
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>();
|
|
426
472
|
let logSearch = '';
|
|
427
473
|
let logLinesRaw: string[] = []; // Raw text (for search filtering)
|
|
428
474
|
let logLinesHtml: string[] = []; // Pre-converted HTML (cached ansiToHtml)
|
|
@@ -467,6 +513,75 @@ export default function mount(): () => void {
|
|
|
467
513
|
}
|
|
468
514
|
loadVersion();
|
|
469
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
|
+
|
|
470
585
|
// ─── Guard Activity Feed ───
|
|
471
586
|
interface GuardEvent {
|
|
472
587
|
time: number;
|
|
@@ -527,6 +642,35 @@ export default function mount(): () => void {
|
|
|
527
642
|
}
|
|
528
643
|
}
|
|
529
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
|
+
|
|
530
674
|
function updateGroupFilter() {
|
|
531
675
|
const groupFilter = $('group-filter') as HTMLSelectElement;
|
|
532
676
|
if (!groupFilter) return;
|
|
@@ -542,7 +686,12 @@ export default function mount(): () => void {
|
|
|
542
686
|
// Preserve selection if still valid
|
|
543
687
|
if (currentValue && groups.has(currentValue)) {
|
|
544
688
|
groupFilter.value = currentValue;
|
|
689
|
+
} else if (currentValue && !groups.has(currentValue)) {
|
|
690
|
+
groupFilter.value = '';
|
|
691
|
+
groupQuery = '';
|
|
545
692
|
}
|
|
693
|
+
applyDeployConcurrencyPreset(groupFilter.value || '');
|
|
694
|
+
updateDeployPresetScopes();
|
|
546
695
|
}
|
|
547
696
|
|
|
548
697
|
function renderFilteredProcesses() {
|
|
@@ -566,7 +715,16 @@ export default function mount(): () => void {
|
|
|
566
715
|
if (groupQuery) {
|
|
567
716
|
filtered = filtered.filter(p => p.group === groupQuery);
|
|
568
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
|
+
}
|
|
569
726
|
renderProcesses(filtered);
|
|
727
|
+
updateDeployAllButton();
|
|
570
728
|
|
|
571
729
|
// Update search result count badge
|
|
572
730
|
const badge = $('search-count');
|
|
@@ -580,6 +738,30 @@ export default function mount(): () => void {
|
|
|
580
738
|
}
|
|
581
739
|
}
|
|
582
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
|
+
|
|
583
765
|
function updateStats(processes: ProcessData[]) {
|
|
584
766
|
const total = processes.length;
|
|
585
767
|
const running = processes.filter(p => p.running).length;
|
|
@@ -602,6 +784,18 @@ export default function mount(): () => void {
|
|
|
602
784
|
const totalRestarts = processes.reduce((sum, p) => sum + (p.guardRestarts || 0), 0);
|
|
603
785
|
if (rrc) rrc.textContent = String(totalRestarts);
|
|
604
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
|
+
|
|
605
799
|
// Update Guard All button state
|
|
606
800
|
const guardAllBtn = $('guard-all-btn');
|
|
607
801
|
const guardAllLabel = $('guard-all-label');
|
|
@@ -734,7 +928,65 @@ export default function mount(): () => void {
|
|
|
734
928
|
const groupFilter = $('group-filter') as HTMLSelectElement;
|
|
735
929
|
groupFilter?.addEventListener('change', () => {
|
|
736
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();
|
|
737
988
|
renderFilteredProcesses();
|
|
989
|
+
showToast(`Switched to ${scope || 'All Groups'} preset`, 'info');
|
|
738
990
|
});
|
|
739
991
|
|
|
740
992
|
/** Fetch with cache-bust to force fresh data after mutations */
|
|
@@ -1641,26 +1893,134 @@ export default function mount(): () => void {
|
|
|
1641
1893
|
const nameInput = $('process-name-input') as HTMLInputElement;
|
|
1642
1894
|
const cmdInput = $('process-command-input') as HTMLInputElement;
|
|
1643
1895
|
const dirInput = $('process-directory-input') as HTMLInputElement;
|
|
1896
|
+
const portInput = $('process-port-input') as HTMLInputElement;
|
|
1644
1897
|
if (nameInput) nameInput.value = '';
|
|
1645
1898
|
if (cmdInput) cmdInput.value = '';
|
|
1646
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 */ }
|
|
1920
|
+
}
|
|
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();
|
|
1647
1975
|
}
|
|
1648
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
|
+
|
|
1649
2003
|
async function createProcess() {
|
|
1650
2004
|
const name = ($('process-name-input') as HTMLInputElement)?.value?.trim();
|
|
1651
2005
|
const command = ($('process-command-input') as HTMLInputElement)?.value?.trim();
|
|
1652
2006
|
const directory = ($('process-directory-input') as HTMLInputElement)?.value?.trim();
|
|
2007
|
+
const portValue = ($('process-port-input') as HTMLInputElement)?.value?.trim();
|
|
1653
2008
|
|
|
1654
2009
|
if (!name || !command || !directory) {
|
|
1655
2010
|
showToast('Please fill in all fields', 'error');
|
|
1656
2011
|
return;
|
|
1657
2012
|
}
|
|
1658
2013
|
|
|
2014
|
+
const body: Record<string, any> = { name, command, directory };
|
|
2015
|
+
if (portValue) {
|
|
2016
|
+
body.env = { PORT: portValue };
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1659
2019
|
try {
|
|
1660
2020
|
const res = await fetch('/api/start', {
|
|
1661
2021
|
method: 'POST',
|
|
1662
2022
|
headers: { 'Content-Type': 'application/json' },
|
|
1663
|
-
body: JSON.stringify(
|
|
2023
|
+
body: JSON.stringify(body),
|
|
1664
2024
|
});
|
|
1665
2025
|
|
|
1666
2026
|
if (res.ok) {
|
|
@@ -1688,6 +2048,312 @@ export default function mount(): () => void {
|
|
|
1688
2048
|
}
|
|
1689
2049
|
});
|
|
1690
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
|
+
|
|
1691
2357
|
// ─── Templates Modal ───
|
|
1692
2358
|
|
|
1693
2359
|
interface TemplateData {
|
|
@@ -1853,7 +2519,32 @@ export default function mount(): () => void {
|
|
|
1853
2519
|
metadata: Record<string, any>;
|
|
1854
2520
|
}
|
|
1855
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
|
+
|
|
1856
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
|
+
})();
|
|
1857
2548
|
|
|
1858
2549
|
async function loadHistory() {
|
|
1859
2550
|
try {
|
|
@@ -1888,14 +2579,233 @@ export default function mount(): () => void {
|
|
|
1888
2579
|
}
|
|
1889
2580
|
}
|
|
1890
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
|
+
|
|
1891
2796
|
function renderHistory() {
|
|
1892
2797
|
const list = $('history-list');
|
|
1893
2798
|
const processFilter = $('history-process-filter') as HTMLSelectElement;
|
|
1894
2799
|
const eventFilter = $('history-event-filter') as HTMLSelectElement;
|
|
2800
|
+
const metadataFilter = $('history-metadata-filter') as HTMLInputElement;
|
|
1895
2801
|
if (!list) return;
|
|
1896
2802
|
|
|
1897
2803
|
const processValue = processFilter?.value || '';
|
|
1898
2804
|
const eventValue = eventFilter?.value || '';
|
|
2805
|
+
const metadataTerms = (metadataFilter?.value || '')
|
|
2806
|
+
.split(',')
|
|
2807
|
+
.map(v => v.toLowerCase().trim())
|
|
2808
|
+
.filter(Boolean);
|
|
1899
2809
|
|
|
1900
2810
|
let filtered = allHistory;
|
|
1901
2811
|
if (processValue) {
|
|
@@ -1904,30 +2814,189 @@ export default function mount(): () => void {
|
|
|
1904
2814
|
if (eventValue) {
|
|
1905
2815
|
filtered = filtered.filter(h => h.event === eventValue);
|
|
1906
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();
|
|
1907
2833
|
|
|
1908
2834
|
if (filtered.length === 0) {
|
|
1909
2835
|
list.innerHTML = '<div class="history-empty">No history found</div>';
|
|
2836
|
+
focusedHistoryIndex = 0;
|
|
2837
|
+
focusedHistoryKey = null;
|
|
2838
|
+
updateHistoryFocusStatus();
|
|
1910
2839
|
return;
|
|
1911
2840
|
}
|
|
1912
2841
|
|
|
1913
|
-
list.replaceChildren(...filtered.map(h => {
|
|
2842
|
+
list.replaceChildren(...filtered.map((h, index) => {
|
|
1914
2843
|
const time = new Date(h.timestamp);
|
|
1915
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');
|
|
1916
2848
|
return (
|
|
1917
|
-
<div className="history-item">
|
|
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">
|
|
1918
2850
|
<span className="history-item-time">{timeStr}</span>
|
|
1919
|
-
<
|
|
1920
|
-
|
|
1921
|
-
|
|
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>
|
|
1922
2949
|
</div>
|
|
1923
2950
|
) as unknown as Node;
|
|
1924
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);
|
|
1925
2976
|
}
|
|
1926
2977
|
|
|
1927
|
-
function
|
|
2978
|
+
async function openHistoryModalWithFilters(filters?: { process?: string; event?: string; metadata?: string; focus?: { process?: string; event?: string } }) {
|
|
1928
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
|
+
|
|
1929
2984
|
if (modal) modal.classList.add('active');
|
|
1930
|
-
loadHistory();
|
|
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();
|
|
1931
3000
|
}
|
|
1932
3001
|
|
|
1933
3002
|
function closeHistoryModal() {
|
|
@@ -1944,6 +3013,329 @@ export default function mount(): () => void {
|
|
|
1944
3013
|
});
|
|
1945
3014
|
$('history-process-filter')?.addEventListener('change', renderHistory);
|
|
1946
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
|
+
});
|
|
1947
3339
|
|
|
1948
3340
|
// ─── Toolbar Actions ───
|
|
1949
3341
|
$('refresh-btn')?.addEventListener('click', () => {
|
|
@@ -1994,6 +3386,107 @@ export default function mount(): () => void {
|
|
|
1994
3386
|
mutationUntil = Date.now() + 3000;
|
|
1995
3387
|
});
|
|
1996
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
|
+
|
|
1997
3490
|
// Group toggle removed — always-on directory grouping
|
|
1998
3491
|
|
|
1999
3492
|
// ─── Keyboard Shortcuts ───
|
|
@@ -2071,6 +3564,66 @@ export default function mount(): () => void {
|
|
|
2071
3564
|
function handleKeydown(e: KeyboardEvent) {
|
|
2072
3565
|
// Skip all shortcuts when inside text inputs or textareas
|
|
2073
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
|
+
}
|
|
2074
3627
|
|
|
2075
3628
|
// "/" to focus search (unless already in an input)
|
|
2076
3629
|
if (e.key === '/' && !inInput) {
|
|
@@ -2090,6 +3643,10 @@ export default function mount(): () => void {
|
|
|
2090
3643
|
closeContextMenu();
|
|
2091
3644
|
return;
|
|
2092
3645
|
}
|
|
3646
|
+
if (historyOpen) {
|
|
3647
|
+
closeHistoryModal();
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
2093
3650
|
if (drawer?.classList.contains('open')) {
|
|
2094
3651
|
closeDrawer();
|
|
2095
3652
|
} else {
|
|
@@ -2140,6 +3697,17 @@ export default function mount(): () => void {
|
|
|
2140
3697
|
return;
|
|
2141
3698
|
}
|
|
2142
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
|
+
|
|
2143
3711
|
// Process actions — require a focused row
|
|
2144
3712
|
if (!focusedProcessName) return;
|
|
2145
3713
|
|