bgrun 3.12.0 → 3.12.2

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