bgrun 3.12.1 → 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.
@@ -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({ name, command, directory }),
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
- <span className="history-item-process">{h.process_name}</span>
1920
- <span className={`history-item-event ${h.event}`}>{h.event.replace('_', ' ')}</span>
1921
- {h.pid && <span className="history-item-pid">PID {h.pid}</span>}
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 openHistoryModal() {
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