bgrun 3.12.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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'}`}>
@@ -413,6 +414,7 @@ export default function mount(): () => void {
413
414
  let isFirstLoad = true;
414
415
  let allProcesses: ProcessData[] = [];
415
416
  let searchQuery = '';
417
+ let groupQuery = '';
416
418
  let searchDebounce: ReturnType<typeof setTimeout> | null = null;
417
419
  let collapsedGroups: Set<string> = new Set(JSON.parse(localStorage.getItem('bgr_collapsed_groups') || '[]'));
418
420
  let drawerProcess: string | null = null;
@@ -465,6 +467,48 @@ export default function mount(): () => void {
465
467
  }
466
468
  loadVersion();
467
469
 
470
+ // ─── Guard Activity Feed ───
471
+ interface GuardEvent {
472
+ time: number;
473
+ name: string;
474
+ action: string;
475
+ success: boolean;
476
+ }
477
+
478
+ async function loadGuardEvents() {
479
+ const listEl = $('guard-activity-list');
480
+ const emptyEl = $('guard-activity-empty');
481
+ if (!listEl) return;
482
+ try {
483
+ const res = await fetch('/api/guard-events');
484
+ const events: GuardEvent[] = await res.json();
485
+ if (events.length === 0) {
486
+ if (emptyEl) emptyEl.style.display = '';
487
+ listEl.innerHTML = '';
488
+ return;
489
+ }
490
+ if (emptyEl) emptyEl.style.display = 'none';
491
+ listEl.replaceChildren(...events.slice(0, 10).map(ev => {
492
+ const date = new Date(ev.time);
493
+ const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
494
+ const icon = ev.success ? '↻' : '✕';
495
+ const actionText = ev.action === 'restart' ? 'restarted' : ev.action;
496
+ return (
497
+ <div className={`guard-event ${ev.success ? 'success' : 'failed'}`}>
498
+ <span className="guard-event-time">{timeStr}</span>
499
+ <span className="guard-event-icon">{icon}</span>
500
+ <span className="guard-event-name">{ev.name}</span>
501
+ <span className="guard-event-action">{actionText}</span>
502
+ </div>
503
+ ) as unknown as Node;
504
+ }));
505
+ } catch {
506
+ if (emptyEl) emptyEl.style.display = '';
507
+ }
508
+ }
509
+ loadGuardEvents();
510
+ setInterval(loadGuardEvents, 10000); // Refresh every 10s
511
+
468
512
  // ─── Load & Render Processes ───
469
513
 
470
514
  async function loadProcesses() {
@@ -473,6 +517,7 @@ export default function mount(): () => void {
473
517
  try {
474
518
  const res = await fetch('/api/processes');
475
519
  allProcesses = await res.json();
520
+ updateGroupFilter();
476
521
  renderFilteredProcesses();
477
522
  updateStats(allProcesses);
478
523
  } catch (err) {
@@ -482,18 +527,45 @@ export default function mount(): () => void {
482
527
  }
483
528
  }
484
529
 
530
+ function updateGroupFilter() {
531
+ const groupFilter = $('group-filter') as HTMLSelectElement;
532
+ if (!groupFilter) return;
533
+ const groups = new Set<string>();
534
+ for (const p of allProcesses) {
535
+ if (p.group) groups.add(p.group);
536
+ }
537
+ const currentValue = groupFilter.value;
538
+ groupFilter.replaceChildren(
539
+ <option value="">All Groups</option> as unknown as Node,
540
+ ...Array.from(groups).sort().map(g => <option value={g}>{g}</option> as unknown as Node)
541
+ );
542
+ // Preserve selection if still valid
543
+ if (currentValue && groups.has(currentValue)) {
544
+ groupFilter.value = currentValue;
545
+ }
546
+ }
547
+
485
548
  function renderFilteredProcesses() {
486
549
  // Always sync searchQuery from DOM to prevent desync
487
550
  if (searchInput && searchInput.value.toLowerCase().trim() !== searchQuery) {
488
551
  searchQuery = searchInput.value.toLowerCase().trim();
489
552
  }
490
- const filtered = searchQuery
553
+ // Sync groupQuery from dropdown
554
+ const groupFilter = $('group-filter') as HTMLSelectElement;
555
+ if (groupFilter && groupFilter.value !== groupQuery) {
556
+ groupQuery = groupFilter.value;
557
+ }
558
+ let filtered = searchQuery
491
559
  ? allProcesses.filter(p =>
492
560
  p.name.toLowerCase().includes(searchQuery) ||
493
561
  p.command.toLowerCase().includes(searchQuery) ||
494
562
  (p.port && String(p.port).includes(searchQuery))
495
563
  )
496
564
  : allProcesses;
565
+ // Apply group filter
566
+ if (groupQuery) {
567
+ filtered = filtered.filter(p => p.group === groupQuery);
568
+ }
497
569
  renderProcesses(filtered);
498
570
 
499
571
  // Update search result count badge
@@ -657,6 +729,14 @@ export default function mount(): () => void {
657
729
  }, 150);
658
730
  });
659
731
 
732
+ // ─── Group Filter ───
733
+
734
+ const groupFilter = $('group-filter') as HTMLSelectElement;
735
+ groupFilter?.addEventListener('change', () => {
736
+ groupQuery = groupFilter.value;
737
+ renderFilteredProcesses();
738
+ });
739
+
660
740
  /** Fetch with cache-bust to force fresh data after mutations */
661
741
  async function loadProcessesFresh() {
662
742
  isFetching = true;
@@ -1608,6 +1688,263 @@ export default function mount(): () => void {
1608
1688
  }
1609
1689
  });
1610
1690
 
1691
+ // ─── Templates Modal ───
1692
+
1693
+ interface TemplateData {
1694
+ name: string;
1695
+ command: string;
1696
+ workdir: string;
1697
+ env: string;
1698
+ group: string;
1699
+ created_at: string;
1700
+ }
1701
+
1702
+ let templates: TemplateData[] = [];
1703
+
1704
+ async function loadTemplates() {
1705
+ try {
1706
+ const res = await fetch('/api/templates');
1707
+ if (res.ok) {
1708
+ templates = await res.json();
1709
+ renderTemplates();
1710
+ }
1711
+ } catch (err) {
1712
+ console.error('[bgr-dashboard] loadTemplates error:', err);
1713
+ }
1714
+ }
1715
+
1716
+ function renderTemplates() {
1717
+ const list = $('templates-list');
1718
+ if (!list) return;
1719
+
1720
+ if (templates.length === 0) {
1721
+ list.innerHTML = '<div class="templates-empty">No templates saved yet</div>';
1722
+ return;
1723
+ }
1724
+
1725
+ list.replaceChildren(...templates.map(t => (
1726
+ <div className="template-item">
1727
+ <div className="template-item-info">
1728
+ <div className="template-item-name">{t.name}</div>
1729
+ <div className="template-item-command">{t.command}</div>
1730
+ </div>
1731
+ {t.group && <span className="template-item-group">{t.group}</span>}
1732
+ <div className="template-item-actions">
1733
+ <button className="use-btn" data-use={t.name} title="Use this template">Use</button>
1734
+ <button className="delete-btn" data-delete={t.name} title="Delete template">✕</button>
1735
+ </div>
1736
+ </div>
1737
+ ) as unknown as Node));
1738
+
1739
+ // Add click handlers
1740
+ list.querySelectorAll('.use-btn').forEach(btn => {
1741
+ btn.addEventListener('click', (e) => {
1742
+ const name = (e.target as HTMLElement).dataset.use;
1743
+ const tmpl = templates.find(t => t.name === name);
1744
+ if (tmpl) {
1745
+ useTemplate(tmpl);
1746
+ }
1747
+ });
1748
+ });
1749
+
1750
+ list.querySelectorAll('.delete-btn').forEach(btn => {
1751
+ btn.addEventListener('click', (e) => {
1752
+ const name = (e.target as HTMLElement).dataset.delete;
1753
+ if (name) deleteTemplate(name);
1754
+ });
1755
+ });
1756
+ }
1757
+
1758
+ function openTemplatesModal() {
1759
+ const modal = $('templates-modal');
1760
+ if (modal) modal.classList.add('active');
1761
+ loadTemplates();
1762
+ }
1763
+
1764
+ function closeTemplatesModal() {
1765
+ const modal = $('templates-modal');
1766
+ if (modal) modal.classList.remove('active');
1767
+ // Clear form
1768
+ ($('template-name') as HTMLInputElement).value = '';
1769
+ ($('template-command') as HTMLInputElement).value = '';
1770
+ ($('template-directory') as HTMLInputElement).value = '';
1771
+ ($('template-group') as HTMLInputElement).value = '';
1772
+ ($('template-env') as HTMLInputElement).value = '';
1773
+ }
1774
+
1775
+ async function saveTemplate() {
1776
+ const name = ($('template-name') as HTMLInputElement)?.value?.trim();
1777
+ const command = ($('template-command') as HTMLInputElement)?.value?.trim();
1778
+ const workdir = ($('template-directory') as HTMLInputElement)?.value?.trim();
1779
+ const group = ($('template-group') as HTMLInputElement)?.value?.trim();
1780
+ const env = ($('template-env') as HTMLInputElement)?.value?.trim();
1781
+
1782
+ if (!name || !command) {
1783
+ showToast('Name and command are required', 'error');
1784
+ return;
1785
+ }
1786
+
1787
+ try {
1788
+ const res = await fetch('/api/templates', {
1789
+ method: 'POST',
1790
+ headers: { 'Content-Type': 'application/json' },
1791
+ body: JSON.stringify({ name, command, workdir, group, env }),
1792
+ });
1793
+
1794
+ if (res.ok) {
1795
+ showToast(`Template "${name}" saved`, 'success');
1796
+ loadTemplates();
1797
+ // Clear form
1798
+ ($('template-name') as HTMLInputElement).value = '';
1799
+ ($('template-command') as HTMLInputElement).value = '';
1800
+ ($('template-directory') as HTMLInputElement).value = '';
1801
+ ($('template-group') as HTMLInputElement).value = '';
1802
+ ($('template-env') as HTMLInputElement).value = '';
1803
+ } else {
1804
+ showToast('Failed to save template', 'error');
1805
+ }
1806
+ } catch (err) {
1807
+ showToast('Failed to save template', 'error');
1808
+ }
1809
+ }
1810
+
1811
+ async function deleteTemplate(name: string) {
1812
+ if (!confirm(`Delete template "${name}"?`)) return;
1813
+
1814
+ try {
1815
+ const res = await fetch(`/api/templates?name=${encodeURIComponent(name)}`, { method: 'DELETE' });
1816
+ if (res.ok) {
1817
+ showToast(`Template "${name}" deleted`, 'success');
1818
+ loadTemplates();
1819
+ } else {
1820
+ showToast('Failed to delete template', 'error');
1821
+ }
1822
+ } catch (err) {
1823
+ showToast('Failed to delete template', 'error');
1824
+ }
1825
+ }
1826
+
1827
+ function useTemplate(tmpl: TemplateData) {
1828
+ // Fill new process form with template values
1829
+ ($('process-name-input') as HTMLInputElement).value = '';
1830
+ ($('process-command-input') as HTMLInputElement).value = tmpl.command;
1831
+ ($('process-directory-input') as HTMLInputElement).value = tmpl.workdir;
1832
+ closeTemplatesModal();
1833
+ openModal();
1834
+ showToast(`Template "${tmpl.name}" loaded — enter a process name`, 'success');
1835
+ }
1836
+
1837
+ $('templates-btn')?.addEventListener('click', openTemplatesModal);
1838
+ $('templates-modal-close')?.addEventListener('click', closeTemplatesModal);
1839
+ $('template-save-btn')?.addEventListener('click', saveTemplate);
1840
+ $('templates-modal')?.addEventListener('click', (e) => {
1841
+ if ((e.target as Element).classList.contains('modal-overlay')) {
1842
+ closeTemplatesModal();
1843
+ }
1844
+ });
1845
+
1846
+ // ─── History Modal ───
1847
+
1848
+ interface HistoryEntry {
1849
+ process_name: string;
1850
+ event: string;
1851
+ pid: number | null;
1852
+ timestamp: string;
1853
+ metadata: Record<string, any>;
1854
+ }
1855
+
1856
+ let allHistory: HistoryEntry[] = [];
1857
+
1858
+ async function loadHistory() {
1859
+ try {
1860
+ const res = await fetch('/api/history?limit=100');
1861
+ if (res.ok) {
1862
+ allHistory = await res.json();
1863
+ renderHistory();
1864
+ updateHistoryFilters();
1865
+ }
1866
+ } catch (err) {
1867
+ console.error('[bgr-dashboard] loadHistory error:', err);
1868
+ }
1869
+ }
1870
+
1871
+ function updateHistoryFilters() {
1872
+ const processFilter = $('history-process-filter') as HTMLSelectElement;
1873
+ const eventFilter = $('history-event-filter') as HTMLSelectElement;
1874
+ if (!processFilter) return;
1875
+
1876
+ const processNames = new Set<string>();
1877
+ for (const h of allHistory) {
1878
+ processNames.add(h.process_name);
1879
+ }
1880
+
1881
+ const currentValue = processFilter.value;
1882
+ processFilter.replaceChildren(
1883
+ <option value="">All Processes</option> as unknown as Node,
1884
+ ...Array.from(processNames).sort().map(n => <option value={n}>{n}</option> as unknown as Node)
1885
+ );
1886
+ if (currentValue && processNames.has(currentValue)) {
1887
+ processFilter.value = currentValue;
1888
+ }
1889
+ }
1890
+
1891
+ function renderHistory() {
1892
+ const list = $('history-list');
1893
+ const processFilter = $('history-process-filter') as HTMLSelectElement;
1894
+ const eventFilter = $('history-event-filter') as HTMLSelectElement;
1895
+ if (!list) return;
1896
+
1897
+ const processValue = processFilter?.value || '';
1898
+ const eventValue = eventFilter?.value || '';
1899
+
1900
+ let filtered = allHistory;
1901
+ if (processValue) {
1902
+ filtered = filtered.filter(h => h.process_name === processValue);
1903
+ }
1904
+ if (eventValue) {
1905
+ filtered = filtered.filter(h => h.event === eventValue);
1906
+ }
1907
+
1908
+ if (filtered.length === 0) {
1909
+ list.innerHTML = '<div class="history-empty">No history found</div>';
1910
+ return;
1911
+ }
1912
+
1913
+ list.replaceChildren(...filtered.map(h => {
1914
+ const time = new Date(h.timestamp);
1915
+ const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' ' + time.toLocaleDateString([], { month: 'short', day: 'numeric' });
1916
+ return (
1917
+ <div className="history-item">
1918
+ <span className="history-item-time">{timeStr}</span>
1919
+ <span className="history-item-process">{h.process_name}</span>
1920
+ <span className={`history-item-event ${h.event}`}>{h.event.replace('_', ' ')}</span>
1921
+ {h.pid && <span className="history-item-pid">PID {h.pid}</span>}
1922
+ </div>
1923
+ ) as unknown as Node;
1924
+ }));
1925
+ }
1926
+
1927
+ function openHistoryModal() {
1928
+ const modal = $('history-modal');
1929
+ if (modal) modal.classList.add('active');
1930
+ loadHistory();
1931
+ }
1932
+
1933
+ function closeHistoryModal() {
1934
+ const modal = $('history-modal');
1935
+ if (modal) modal.classList.remove('active');
1936
+ }
1937
+
1938
+ $('history-btn')?.addEventListener('click', openHistoryModal);
1939
+ $('history-modal-close')?.addEventListener('click', closeHistoryModal);
1940
+ $('history-modal')?.addEventListener('click', (e) => {
1941
+ if ((e.target as Element).classList.contains('modal-overlay')) {
1942
+ closeHistoryModal();
1943
+ }
1944
+ });
1945
+ $('history-process-filter')?.addEventListener('change', renderHistory);
1946
+ $('history-event-filter')?.addEventListener('change', renderHistory);
1947
+
1611
1948
  // ─── Toolbar Actions ───
1612
1949
  $('refresh-btn')?.addEventListener('click', () => {
1613
1950
  loadProcesses();
@@ -39,6 +39,15 @@ export default function DashboardPage() {
39
39
  </div>
40
40
  </div>
41
41
 
42
+ {/* Guard Activity Feed */}
43
+ <div className="guard-activity" id="guard-activity">
44
+ <div className="guard-activity-header">
45
+ <span className="guard-activity-title">🛡️ Guard Activity</span>
46
+ <span className="guard-activity-empty" id="guard-activity-empty">No recent activity</span>
47
+ </div>
48
+ <div className="guard-activity-list" id="guard-activity-list"></div>
49
+ </div>
50
+
42
51
  {/* Toolbar */}
43
52
  <div className="toolbar">
44
53
  <div className="toolbar-left">
@@ -56,6 +65,9 @@ export default function DashboardPage() {
56
65
  <span className="search-count" id="search-count" style={{ display: 'none' }}></span>
57
66
  <span className="search-shortcut">/</span>
58
67
  </div>
68
+ <select className="group-filter" id="group-filter">
69
+ <option value="">All Groups</option>
70
+ </select>
59
71
  </div>
60
72
  <div className="toolbar-right">
61
73
  <button className="btn btn-ghost btn-icon" id="refresh-btn" title="Refresh">
@@ -77,6 +89,20 @@ export default function DashboardPage() {
77
89
  <button className="btn btn-ghost btn-icon" id="shortcuts-btn" title="Keyboard Shortcuts (?)">
78
90
  <span style={{ fontSize: '0.85rem', fontWeight: '700' }}>?</span>
79
91
  </button>
92
+ <button className="btn btn-secondary" id="templates-btn">
93
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
94
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
95
+ <polyline points="14 2 14 8 20 8" />
96
+ </svg>
97
+ Templates
98
+ </button>
99
+ <button className="btn btn-ghost" id="history-btn">
100
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
101
+ <circle cx="12" cy="12" r="10" />
102
+ <polyline points="12 6 12 12 16 14" />
103
+ </svg>
104
+ History
105
+ </button>
80
106
  <button className="btn btn-primary" id="new-process-btn">
81
107
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
82
108
  <line x1="12" y1="5" x2="12" y2="19" />
@@ -226,6 +252,78 @@ export default function DashboardPage() {
226
252
  </div>
227
253
  </div>
228
254
 
255
+ {/* History Modal */}
256
+ <div className="modal-overlay" id="history-modal">
257
+ <div className="modal modal-wide">
258
+ <div className="modal-header">
259
+ <h3>📜 Process History</h3>
260
+ <button className="modal-close" id="history-modal-close">✕</button>
261
+ </div>
262
+ <div className="modal-body">
263
+ <div className="history-filters">
264
+ <select id="history-process-filter" className="history-select">
265
+ <option value="">All Processes</option>
266
+ </select>
267
+ <select id="history-event-filter" className="history-select">
268
+ <option value="">All Events</option>
269
+ <option value="start">Start</option>
270
+ <option value="stop">Stop</option>
271
+ <option value="restart">Restart</option>
272
+ <option value="guard_on">Guard On</option>
273
+ <option value="guard_off">Guard Off</option>
274
+ </select>
275
+ </div>
276
+ <div className="history-list" id="history-list">
277
+ <div className="history-empty">No history yet</div>
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </div>
282
+
283
+ {/* Templates Modal */}
284
+ <div className="modal-overlay" id="templates-modal">
285
+ <div className="modal modal-wide">
286
+ <div className="modal-header">
287
+ <h3>📋 Process Templates</h3>
288
+ <button className="modal-close" id="templates-modal-close">✕</button>
289
+ </div>
290
+ <div className="modal-body">
291
+ <div className="templates-form">
292
+ <div className="form-row">
293
+ <div className="form-group">
294
+ <label htmlFor="template-name">Template Name</label>
295
+ <input type="text" id="template-name" placeholder="my-template" />
296
+ </div>
297
+ <div className="form-group">
298
+ <label htmlFor="template-command">Command</label>
299
+ <input type="text" id="template-command" placeholder="bun run dev" />
300
+ </div>
301
+ </div>
302
+ <div className="form-row">
303
+ <div className="form-group">
304
+ <label htmlFor="template-directory">Working Directory</label>
305
+ <input type="text" id="template-directory" placeholder="/path/to/project" />
306
+ </div>
307
+ <div className="form-group">
308
+ <label htmlFor="template-group">Group</label>
309
+ <input type="text" id="template-group" placeholder="my-group" />
310
+ </div>
311
+ </div>
312
+ <div className="form-group">
313
+ <label htmlFor="template-env">Environment (KEY=VAL,KEY2=VAL2)</label>
314
+ <input type="text" id="template-env" placeholder="BGR_KEEP_ALIVE=true,PORT=3000" />
315
+ </div>
316
+ <div className="templates-actions">
317
+ <button className="btn btn-primary" id="template-save-btn">Save Template</button>
318
+ </div>
319
+ </div>
320
+ <div className="templates-list" id="templates-list">
321
+ <div className="templates-empty">No templates saved yet</div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+ </div>
326
+
229
327
  {/* Keyboard Shortcuts Overlay */}
230
328
  <div className="shortcuts-overlay" id="shortcuts-overlay">
231
329
  <div className="shortcuts-panel">