bgrun 3.4.0 → 3.7.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.
@@ -1,23 +1,31 @@
1
+ /** @jsxImportSource react */
1
2
  /**
2
3
  * bgrun Dashboard — Page Client Interactivity
3
4
  *
4
5
  * NOT a React component. A mount function that adds interactivity
5
6
  * to the server-rendered HTML. JSX here creates real DOM elements
6
7
  * (via Melina's jsx-dom runtime, not React virtual DOM).
8
+ *
9
+ * Log viewer uses Melina's VDOM render() with keyed reconciler
10
+ * for efficient incremental DOM updates.
7
11
  */
12
+ import { render as melinaRender, createElement as h, setReconciler } from 'melina/client/render';
8
13
 
9
14
  interface ProcessData {
10
15
  name: string;
11
- command: string;
12
- directory: string;
13
16
  pid: number;
14
17
  running: boolean;
15
- port: number | null;
16
- ports: number[];
17
- memory: number; // bytes
18
- group: string | null;
19
- runtime: string;
18
+ port: string;
19
+ command: string;
20
+ memory: number;
21
+ runtime: number;
22
+ directory: string;
23
+ group?: string;
20
24
  timestamp: string;
25
+ env: string;
26
+ configPath: string;
27
+ stdoutPath: string;
28
+ stderrPath: string;
21
29
  }
22
30
 
23
31
  // ─── SVG Icon Helpers ───
@@ -103,6 +111,29 @@ function formatMemory(bytes: number): string {
103
111
  return `${(mb / 1024).toFixed(1)} GB`;
104
112
  }
105
113
 
114
+ function formatTimeAgo(date: Date): string {
115
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
116
+ if (seconds < 10) return 'just now';
117
+ if (seconds < 60) return `${seconds}s ago`;
118
+ const minutes = Math.floor(seconds / 60);
119
+ if (minutes < 60) return `${minutes}m ago`;
120
+ const hours = Math.floor(minutes / 60);
121
+ if (hours < 24) return `${hours}h ago`;
122
+ const days = Math.floor(hours / 24);
123
+ return `${days}d ago`;
124
+ }
125
+
126
+ // ─── Helpers ───
127
+
128
+ function shortenPath(dir: string): string {
129
+ if (!dir) return '';
130
+ const normalized = dir.replace(/\\/g, '/');
131
+ const parts = normalized.split('/');
132
+ // Show last 2 segments (e.g. "Code/bgr" instead of "c:/Code/bgr")
133
+ if (parts.length > 2) return parts.slice(-2).join('/');
134
+ return normalized;
135
+ }
136
+
106
137
  // ─── JSX Components ───
107
138
 
108
139
  function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
@@ -110,7 +141,6 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
110
141
  <tr data-process-name={p.name} className={animate ? 'animate-in' : ''} style={animate ? { opacity: '0' } : undefined}>
111
142
  <td>
112
143
  <div className="process-name">
113
- <div className="process-icon">{p.name.charAt(0).toUpperCase()}</div>
114
144
  <span>{p.name}</span>
115
145
  </div>
116
146
  </td>
@@ -147,6 +177,9 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
147
177
  <PlayIcon />
148
178
  </button>
149
179
  }
180
+ <button className="action-btn warning" data-action="restart" data-name={p.name} title="Restart">
181
+ <RestartIcon />
182
+ </button>
150
183
  <button className="action-btn danger" data-action="delete" data-name={p.name} title="Delete">
151
184
  <TrashIcon />
152
185
  </button>
@@ -155,13 +188,20 @@ function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
155
188
  );
156
189
  }
157
190
 
158
- function GroupHeader({ name }: { name: string }) {
191
+ function GroupHeader({ name, running, total, collapsed }: { name: string; running: number; total: number; collapsed: boolean }) {
192
+ // Show short folder name as label, full path as title
193
+ const shortName = shortenPath(name);
159
194
  return (
160
- <tr className="group-header">
195
+ <tr className={`group-header ${collapsed ? 'collapsed' : ''}`} data-group-name={name}>
161
196
  <td colSpan={8}>
162
- <div className="group-label">
163
- <span className="group-icon">📂</span>
164
- {name}
197
+ <div className="group-label" title={name}>
198
+ <svg className="group-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18l6-6-6-6" /></svg>
199
+ <span className="group-name">{shortName}</span>
200
+ <span className="group-counts">
201
+ <span className={`group-count-running ${running > 0 ? 'has-running' : ''}`}>{running} running</span>
202
+ <span className="group-count-sep">·</span>
203
+ <span className="group-count-total">{total} total</span>
204
+ </span>
165
205
  </div>
166
206
  </td>
167
207
  </tr>
@@ -182,8 +222,95 @@ function EmptyState() {
182
222
  );
183
223
  }
184
224
 
185
- function LogLine({ text }: { text: string }) {
186
- return <div className="log-line">{text}</div>;
225
+ function ProcessCard({ p }: { p: ProcessData }) {
226
+ return (
227
+ <div className="process-card" data-process-name={p.name}>
228
+ <div className="card-header">
229
+ <div className="process-name">
230
+ <span>{p.name}</span>
231
+ </div>
232
+ <span className={`status-badge ${p.running ? 'running' : 'stopped'}`}>
233
+ <span className="status-dot"></span>
234
+ {p.running ? 'Running' : 'Stopped'}
235
+ </span>
236
+ </div>
237
+ <div className="card-details">
238
+ <div className="card-detail"><span className="card-label">PID</span><span>{p.pid}</span></div>
239
+ <div className="card-detail"><span className="card-label">Port</span><span>{p.port ? `:${p.port}` : '–'}</span></div>
240
+ <div className="card-detail"><span className="card-label">Memory</span><span>{p.memory > 0 ? formatMemory(p.memory) : '–'}</span></div>
241
+ <div className="card-detail"><span className="card-label">Runtime</span><span>{formatRuntime(p.runtime)}</span></div>
242
+ </div>
243
+ <div className="card-command" title={p.command}>{p.command}</div>
244
+ <div className="card-actions">
245
+ <button className="action-btn info" data-action="logs" data-name={p.name} title="View Logs">
246
+ <LogsIcon /> Logs
247
+ </button>
248
+ {p.running
249
+ ? <button className="action-btn danger" data-action="stop" data-name={p.name} title="Stop">
250
+ <StopIcon /> Stop
251
+ </button>
252
+ : <button className="action-btn success" data-action="restart" data-name={p.name} title="Start">
253
+ <PlayIcon /> Start
254
+ </button>
255
+ }
256
+ <button className="action-btn warning" data-action="restart" data-name={p.name} title="Restart">
257
+ <RestartIcon /> Restart
258
+ </button>
259
+ <button className="action-btn danger" data-action="delete" data-name={p.name} title="Delete">
260
+ <TrashIcon /> Delete
261
+ </button>
262
+ </div>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ // ─── ANSI to HTML converter ───
268
+ const ANSI_COLORS: Record<number, string> = {
269
+ 30: '#6e7681', 31: '#ff7b72', 32: '#7ee787', 33: '#d2a458',
270
+ 34: '#79c0ff', 35: '#d2a8ff', 36: '#a5d6ff', 37: '#c9d1d9',
271
+ 90: '#8b949e', 91: '#ffa198', 92: '#aff5b4', 93: '#f8e3a1',
272
+ 94: '#a5d6ff', 95: '#e2c5ff', 96: '#b6e3ff', 97: '#f0f6fc',
273
+ };
274
+ const ANSI_BG: Record<number, string> = {
275
+ 40: '#6e7681', 41: '#ff7b72', 42: '#7ee787', 43: '#d2a458',
276
+ 44: '#79c0ff', 45: '#d2a8ff', 46: '#a5d6ff', 47: '#c9d1d9',
277
+ };
278
+
279
+ function ansiToHtml(text: string): string {
280
+ let result = '';
281
+ let openSpans = 0;
282
+ const parts = text.split(/(\x1b\[[0-9;]*m)/);
283
+
284
+ for (const part of parts) {
285
+ const match = part.match(/^\x1b\[([0-9;]*)m$/);
286
+ if (!match) {
287
+ // Escape HTML entities
288
+ result += part.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
289
+ continue;
290
+ }
291
+
292
+ const codes = match[1].split(';').map(Number);
293
+ for (const code of codes) {
294
+ if (code === 0) {
295
+ // Reset
296
+ while (openSpans > 0) { result += '</span>'; openSpans--; }
297
+ } else if (code === 1) {
298
+ result += '<span style="font-weight:bold">'; openSpans++;
299
+ } else if (code === 2) {
300
+ result += '<span style="opacity:0.6">'; openSpans++;
301
+ } else if (code === 3) {
302
+ result += '<span style="font-style:italic">'; openSpans++;
303
+ } else if (code === 4) {
304
+ result += '<span style="text-decoration:underline">'; openSpans++;
305
+ } else if (ANSI_COLORS[code]) {
306
+ result += `<span style="color:${ANSI_COLORS[code]}">`; openSpans++;
307
+ } else if (ANSI_BG[code]) {
308
+ result += `<span style="background:${ANSI_BG[code]}">`; openSpans++;
309
+ }
310
+ }
311
+ }
312
+ while (openSpans > 0) { result += '</span>'; openSpans--; }
313
+ return result;
187
314
  }
188
315
 
189
316
  // ─── Toast System ───
@@ -214,14 +341,24 @@ function showToast(message: string, type: 'success' | 'error' | 'info' = 'info')
214
341
  export default function mount(): () => void {
215
342
  const $ = (id: string) => document.getElementById(id);
216
343
  let selectedProcess: string | null = null;
217
- let refreshTimer: ReturnType<typeof setInterval> | null = null;
218
344
  let isFetching = false;
219
345
  let isFirstLoad = true;
220
346
  let allProcesses: ProcessData[] = [];
221
347
  let searchQuery = '';
222
- let isGrouped = localStorage.getItem('bgr_grouped') === 'true'; // Persist preference
348
+ let collapsedGroups: Set<string> = new Set(JSON.parse(localStorage.getItem('bgr_collapsed_groups') || '[]'));
223
349
  let drawerProcess: string | null = null;
224
350
  let drawerTab: 'stdout' | 'stderr' = 'stdout';
351
+ let activeSection = 'logs'; // Which accordion section is open: 'info' | 'config' | 'logs'
352
+ let mutationUntil = 0; // Timestamp: ignore SSE updates until this time (after mutations)
353
+ let configSubtab = 'toml'; // 'toml' | 'env'
354
+ let logAutoScroll = localStorage.getItem('bgr_autoscroll') === 'true'; // OFF by default
355
+ let logSearch = '';
356
+ let logLinesRaw: string[] = []; // Raw text (for search filtering)
357
+ let logLinesHtml: string[] = []; // Pre-converted HTML (cached ansiToHtml)
358
+ let logOffset = 0; // Byte offset for incremental fetching
359
+ let logCurrentTab = ''; // Track tab to reset on switch
360
+ let logLastSize = -1; // Detect no-change polls
361
+ let logNeedsFullRebuild = true; // Full DOM rebuild flag (on tab switch, search change)
225
362
 
226
363
  // ─── Version Badge ───
227
364
  const versionBadge = $('version-badge');
@@ -269,65 +406,84 @@ export default function mount(): () => void {
269
406
  const total = processes.length;
270
407
  const running = processes.filter(p => p.running).length;
271
408
  const stopped = total - running;
409
+ const totalMemory = processes.reduce((sum, p) => sum + (p.memory || 0), 0);
272
410
 
273
411
  const tc = $('total-count');
274
412
  const rc = $('running-count');
275
413
  const sc = $('stopped-count');
414
+ const mc = $('memory-count');
276
415
  if (tc) tc.textContent = String(total);
277
416
  if (rc) rc.textContent = String(running);
278
417
  if (sc) sc.textContent = String(stopped);
418
+ if (mc) mc.textContent = formatMemory(totalMemory) || '0 MB';
279
419
  }
280
420
 
281
421
  function renderProcesses(processes: ProcessData[]) {
282
422
  const tbody = $('processes-table');
423
+ const cardsEl = $('mobile-cards');
283
424
  if (!tbody) return;
284
425
 
285
426
  if (processes.length === 0) {
286
427
  tbody.replaceChildren(<EmptyState /> as unknown as Node);
428
+ if (cardsEl) cardsEl.replaceChildren(
429
+ <div className="empty-state">
430
+ <div className="empty-icon">📦</div>
431
+ <h3>No processes found</h3>
432
+ <p>Start a new process to see it here</p>
433
+ </div> as unknown as Node
434
+ );
287
435
  return;
288
436
  }
289
437
 
290
438
  const animate = isFirstLoad;
291
439
 
292
- if (isGrouped) {
293
- // Grouping Logic
294
- const groups: Record<string, ProcessData[]> = {};
295
- const ungrouped: ProcessData[] = [];
296
-
297
- processes.forEach(p => {
298
- if (p.group) {
299
- if (!groups[p.group]) groups[p.group] = [];
300
- groups[p.group].push(p);
301
- } else {
302
- ungrouped.push(p);
303
- }
304
- });
305
-
306
- const nodes: Node[] = [];
440
+ // Group by working directory
441
+ const groups: Record<string, ProcessData[]> = {};
442
+ processes.forEach(p => {
443
+ const key = p.directory || 'Unknown';
444
+ if (!groups[key]) groups[key] = [];
445
+ groups[key].push(p);
446
+ });
307
447
 
308
- // Render groups first
309
- Object.keys(groups).sort().forEach(groupName => {
310
- nodes.push(<GroupHeader name={groupName} /> as unknown as Node);
311
- groups[groupName].forEach(p => {
448
+ const nodes: Node[] = [];
449
+ const sortedGroupKeys = Object.keys(groups).sort();
450
+
451
+ // Always show group headers for every directory
452
+ sortedGroupKeys.forEach(groupDir => {
453
+ const procs = groups[groupDir];
454
+ const running = procs.filter(p => p.running).length;
455
+ const collapsed = collapsedGroups.has(groupDir);
456
+ nodes.push(<GroupHeader name={groupDir} running={running} total={procs.length} collapsed={collapsed} /> as unknown as Node);
457
+ if (!collapsed) {
458
+ procs.forEach(p => {
312
459
  nodes.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
313
460
  });
314
- });
461
+ }
462
+ });
315
463
 
316
- // Render ungrouped last (with header if groups exist)
317
- if (ungrouped.length > 0) {
318
- if (Object.keys(groups).length > 0) {
319
- nodes.push(<GroupHeader name="Ungrouped" /> as unknown as Node);
464
+ tbody.replaceChildren(...nodes);
465
+
466
+ // Add click handlers for group headers (toggle collapse)
467
+ tbody.querySelectorAll('.group-header').forEach(header => {
468
+ header.addEventListener('click', (e: Event) => {
469
+ // Don't collapse if clicking action buttons
470
+ if ((e.target as Element).closest('[data-action]')) return;
471
+ const groupName = (header as HTMLElement).dataset.groupName;
472
+ if (!groupName) return;
473
+ if (collapsedGroups.has(groupName)) {
474
+ collapsedGroups.delete(groupName);
475
+ } else {
476
+ collapsedGroups.add(groupName);
320
477
  }
321
- ungrouped.forEach(p => {
322
- nodes.push(<ProcessRow p={p} animate={animate} /> as unknown as Node);
323
- });
324
- }
478
+ localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
479
+ renderFilteredProcesses();
480
+ });
481
+ });
325
482
 
326
- tbody.replaceChildren(...nodes);
327
- } else {
328
- // Standard List View
329
- const rows = processes.map(p => <ProcessRow p={p} animate={animate} /> as unknown as Node);
330
- tbody.replaceChildren(...rows);
483
+ // Render mobile cards
484
+ if (cardsEl) {
485
+ const cards = processes.map(p => <ProcessCard p={p} /> as unknown as Node);
486
+ cardsEl.replaceChildren(...cards);
331
487
  }
332
488
 
333
489
  if (isFirstLoad) isFirstLoad = false;
@@ -347,7 +503,17 @@ export default function mount(): () => void {
347
503
  renderFilteredProcesses();
348
504
  });
349
505
 
350
- // ─── Action Handlers ───
506
+ /** Fetch with cache-bust to force fresh data after mutations */
507
+ async function loadProcessesFresh() {
508
+ isFetching = true;
509
+ try {
510
+ const res = await fetch(`/api/processes?t=${Date.now()}`);
511
+ allProcesses = await res.json();
512
+ renderFilteredProcesses();
513
+ updateStats(allProcesses);
514
+ } catch { /* retry on next tick */ }
515
+ finally { isFetching = false; }
516
+ }
351
517
 
352
518
  async function handleAction(e: Event) {
353
519
  const btn = (e.target as Element).closest('[data-action]') as HTMLElement;
@@ -358,36 +524,76 @@ export default function mount(): () => void {
358
524
  if (!name) return;
359
525
 
360
526
  switch (action) {
361
- case 'stop':
527
+ case 'stop': {
528
+ // Optimistic: mark stopped immediately
529
+ const proc = allProcesses.find(p => p.name === name);
530
+ if (proc) {
531
+ proc.running = false;
532
+ proc.memory = 0;
533
+ renderFilteredProcesses();
534
+ updateStats(allProcesses);
535
+ }
362
536
  try {
363
- await fetch(`/api/stop/${encodeURIComponent(name)}`, { method: 'POST' });
364
- showToast(`Stopped "${name}"`, 'success');
365
- await loadProcesses();
366
- } catch (err: any) {
537
+ const res = await fetch(`/api/stop/${encodeURIComponent(name)}`, { method: 'POST' });
538
+ if (res.ok) {
539
+ showToast(`Stopped "${name}"`, 'success');
540
+ } else {
541
+ const data = await res.json();
542
+ showToast(data.error || `Failed to stop "${name}"`, 'error');
543
+ }
544
+ } catch {
367
545
  showToast(`Failed to stop "${name}"`, 'error');
368
546
  }
547
+ await loadProcessesFresh();
548
+ mutationUntil = Date.now() + 3000;
369
549
  break;
550
+ }
370
551
 
371
- case 'restart':
552
+ case 'restart': {
553
+ // Optimistic: mark running immediately
554
+ const proc = allProcesses.find(p => p.name === name);
555
+ if (proc) {
556
+ proc.running = true;
557
+ renderFilteredProcesses();
558
+ updateStats(allProcesses);
559
+ }
372
560
  try {
373
- await fetch(`/api/restart/${encodeURIComponent(name)}`, { method: 'POST' });
374
- showToast(`Restarted "${name}"`, 'success');
375
- await loadProcesses();
376
- } catch (err: any) {
561
+ const res = await fetch(`/api/restart/${encodeURIComponent(name)}`, { method: 'POST' });
562
+ if (res.ok) {
563
+ showToast(`Restarted "${name}"`, 'success');
564
+ } else {
565
+ const data = await res.json();
566
+ showToast(data.error || `Failed to restart "${name}"`, 'error');
567
+ }
568
+ } catch {
377
569
  showToast(`Failed to restart "${name}"`, 'error');
378
570
  }
571
+ await loadProcessesFresh();
572
+ mutationUntil = Date.now() + 3000;
379
573
  break;
574
+ }
380
575
 
381
- case 'delete':
576
+ case 'delete': {
577
+ // Optimistic: remove from array immediately
578
+ allProcesses = allProcesses.filter(p => p.name !== name);
579
+ renderFilteredProcesses();
580
+ updateStats(allProcesses);
581
+ if (drawerProcess === name) closeDrawer();
382
582
  try {
383
- await fetch(`/api/processes/${encodeURIComponent(name)}`, { method: 'DELETE' });
384
- showToast(`Deleted "${name}"`, 'success');
385
- if (drawerProcess === name) closeDrawer();
386
- await loadProcesses();
387
- } catch (err: any) {
583
+ const res = await fetch(`/api/processes/${encodeURIComponent(name)}`, { method: 'DELETE' });
584
+ if (res.ok) {
585
+ showToast(`Deleted "${name}"`, 'success');
586
+ } else {
587
+ const data = await res.json();
588
+ showToast(data.error || `Failed to delete "${name}"`, 'error');
589
+ }
590
+ } catch {
388
591
  showToast(`Failed to delete "${name}"`, 'error');
389
592
  }
593
+ await loadProcessesFresh();
594
+ mutationUntil = Date.now() + 3000;
390
595
  break;
596
+ }
391
597
 
392
598
  case 'logs':
393
599
  openDrawer(name);
@@ -410,20 +616,119 @@ export default function mount(): () => void {
410
616
  }
411
617
  });
412
618
 
619
+ // Mobile cards click → same delegation
620
+ const mobileCards = $('mobile-cards');
621
+ mobileCards?.addEventListener('click', (e: Event) => {
622
+ const btn = (e.target as Element).closest('[data-action]');
623
+ if (btn) {
624
+ handleAction(e);
625
+ return;
626
+ }
627
+ const card = (e.target as Element).closest('.process-card[data-process-name]') as HTMLElement;
628
+ if (card && card.dataset.processName) {
629
+ openDrawer(card.dataset.processName);
630
+ }
631
+ });
632
+
413
633
  // ─── Detail Drawer ───
414
634
 
415
635
  const drawer = $('detail-drawer');
416
636
  const backdrop = $('drawer-backdrop');
417
637
 
638
+ function openAccordionSection(section: string) {
639
+ activeSection = section;
640
+ const sections = drawer?.querySelectorAll('.accordion-section');
641
+ sections?.forEach(el => {
642
+ const s = el.querySelector('.accordion-trigger')?.getAttribute('data-section');
643
+ el.classList.toggle('open', s === section);
644
+ });
645
+
646
+ // Load data for the opened section
647
+ if (section === 'config') {
648
+ if (configSubtab === 'toml') loadConfigPanel();
649
+ else renderEnvPanel();
650
+ } else if (section === 'logs') {
651
+ refreshDrawerLogs();
652
+ }
653
+ }
654
+
655
+ function switchConfigSubtab(subtab: string) {
656
+ configSubtab = subtab;
657
+ const tomlPanel = $('config-panel-toml');
658
+ const envPanel = $('config-panel-env');
659
+ if (tomlPanel) tomlPanel.style.display = subtab === 'toml' ? '' : 'none';
660
+ if (envPanel) envPanel.style.display = subtab === 'env' ? '' : 'none';
661
+ $('config-subtabs')?.querySelectorAll('.accordion-subtab').forEach(btn => {
662
+ btn.classList.toggle('active', (btn as HTMLElement).dataset.subtab === subtab);
663
+ });
664
+ if (subtab === 'toml') loadConfigPanel();
665
+ else renderEnvPanel();
666
+ }
667
+
668
+ function switchLogSubtab(subtab: string, skipRefresh = false) {
669
+ drawerTab = subtab as 'stdout' | 'stderr';
670
+ $('log-subtabs')?.querySelectorAll('.accordion-subtab').forEach(btn => {
671
+ btn.classList.toggle('active', (btn as HTMLElement).dataset.subtab === subtab);
672
+ });
673
+ logLinesRaw = [];
674
+ logLinesHtml = [];
675
+ logOffset = 0;
676
+ logCurrentTab = '';
677
+ logLastSize = -1;
678
+ logNeedsFullRebuild = true;
679
+ if (!skipRefresh) refreshDrawerLogs();
680
+ }
681
+
682
+ function renderEnvPanel() {
683
+ const envEl = $('drawer-env');
684
+ if (!envEl || !drawerProcess) return;
685
+ const proc = allProcesses.find(p => p.name === drawerProcess);
686
+ if (!proc || !proc.env) {
687
+ envEl.innerHTML = '<div class="env-empty">No environment variables configured</div>';
688
+ return;
689
+ }
690
+ const pairs = proc.env.split(',').filter(Boolean).map(s => {
691
+ const idx = s.indexOf('=');
692
+ return idx > 0 ? [s.slice(0, idx), s.slice(idx + 1)] : [s, ''];
693
+ });
694
+ if (pairs.length === 0) {
695
+ envEl.innerHTML = '<div class="env-empty">No environment variables configured</div>';
696
+ return;
697
+ }
698
+ envEl.innerHTML = pairs.map(([k, v]) =>
699
+ `<div class="env-row"><span class="env-key" title="${k}">${k}</span><span class="env-value">${v}</span></div>`
700
+ ).join('');
701
+ }
702
+
703
+ async function loadConfigPanel() {
704
+ const configEditor = $('config-editor') as HTMLTextAreaElement;
705
+ const configPath = $('config-path');
706
+ if (!configEditor || !drawerProcess) return;
707
+
708
+ try {
709
+ const res = await fetch(`/api/config/${encodeURIComponent(drawerProcess)}`);
710
+ const data = await res.json();
711
+ configEditor.value = data.content || '';
712
+ if (configPath) {
713
+ configPath.textContent = data.path || 'No config file';
714
+ configPath.title = data.path || '';
715
+ }
716
+ if (!data.exists) {
717
+ configEditor.placeholder = 'No .config.toml found for this process';
718
+ }
719
+ } catch {
720
+ configEditor.value = '';
721
+ if (configPath) configPath.textContent = 'Failed to load config';
722
+ }
723
+ }
724
+
418
725
  function openDrawer(name: string) {
419
726
  drawerProcess = name;
420
727
  drawerTab = 'stdout';
421
728
 
422
729
  // Update header
423
730
  const nameEl = $('drawer-process-name');
424
- const iconEl = $('drawer-icon');
425
731
  if (nameEl) nameEl.textContent = name;
426
- if (iconEl) iconEl.textContent = name.charAt(0).toUpperCase();
427
732
 
428
733
  // Update meta info
429
734
  const proc = allProcesses.find(p => p.name === name);
@@ -449,10 +754,11 @@ export default function mount(): () => void {
449
754
  meta.replaceChildren(...items);
450
755
  }
451
756
 
452
- // Update tab state
453
- drawer?.querySelectorAll('.drawer-tab').forEach(tab => {
454
- tab.classList.toggle('active', (tab as HTMLElement).dataset.tab === drawerTab);
455
- });
757
+ // Reset log subtab to stdout (skip auto-refresh, we call it once below)
758
+ switchLogSubtab('stdout', true);
759
+
760
+ // Open logs accordion by default
761
+ openAccordionSection('logs');
456
762
 
457
763
  // Show drawer
458
764
  drawer?.classList.add('open');
@@ -463,7 +769,32 @@ export default function mount(): () => void {
463
769
  const row = tbody?.querySelector(`tr[data-process-name="${name}"]`);
464
770
  if (row) row.classList.add('selected');
465
771
 
466
- refreshDrawerLogs();
772
+ // Fetch stderr line count for badge
773
+ // Note: openAccordionSection('logs') above already calls refreshDrawerLogs()
774
+ updateStderrBadge(name);
775
+ }
776
+
777
+ async function updateStderrBadge(name: string) {
778
+ const badge = $('stderr-badge');
779
+ if (!badge) return;
780
+ try {
781
+ const res = await fetch(`/api/logs/${encodeURIComponent(name)}?tab=stderr&offset=0`);
782
+ const data = await res.json();
783
+ const text: string = data.text || '';
784
+ if (!text.trim()) {
785
+ badge.style.display = 'none';
786
+ return;
787
+ }
788
+ const count = text.split('\n').filter(Boolean).length;
789
+ if (count > 0) {
790
+ badge.textContent = count > 999 ? `${Math.floor(count / 1000)}k` : String(count);
791
+ badge.style.display = '';
792
+ } else {
793
+ badge.style.display = 'none';
794
+ }
795
+ } catch {
796
+ badge.style.display = 'none';
797
+ }
467
798
  }
468
799
 
469
800
  function closeDrawer() {
@@ -473,47 +804,286 @@ export default function mount(): () => void {
473
804
  tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
474
805
  }
475
806
 
807
+ // Use keyed reconciler for efficient log line diffing
808
+ setReconciler('keyed');
809
+
810
+ function fullRebuildLogs(logsEl: HTMLElement) {
811
+ const search = logSearch.toLowerCase();
812
+ if (logLinesRaw.length === 0 || (logLinesRaw.length === 1 && !logLinesRaw[0])) {
813
+ logsEl.innerHTML = '<em style="color: var(--text-muted)">No logs available</em>';
814
+ updateLogCount(0);
815
+ logNeedsFullRebuild = false;
816
+ return;
817
+ }
818
+
819
+ // Build all HTML in one pass using cached ansiToHtml results
820
+ const chunks: string[] = [];
821
+ let count = 0;
822
+ for (let i = 0; i < logLinesRaw.length; i++) {
823
+ if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
824
+ count++;
825
+ const num = i + 1;
826
+ chunks.push(`<div class="log-line" data-ln="${num}"><span class="log-line-num">${num}</span><span class="log-line-content">${logLinesHtml[i]}</span></div>`);
827
+ }
828
+ logsEl.innerHTML = chunks.join('');
829
+ updateLogCount(count);
830
+ logNeedsFullRebuild = false;
831
+ }
832
+
833
+ function appendNewLogLines(logsEl: HTMLElement, startIndex: number) {
834
+ // Fast path: append only new lines to existing DOM
835
+ const search = logSearch.toLowerCase();
836
+ const fragment = document.createDocumentFragment();
837
+ let count = 0;
838
+ for (let i = startIndex; i < logLinesRaw.length; i++) {
839
+ if (search && !logLinesRaw[i].toLowerCase().includes(search)) continue;
840
+ count++;
841
+ const div = document.createElement('div');
842
+ div.className = 'log-line';
843
+ div.setAttribute('data-ln', String(i + 1));
844
+ div.innerHTML = `<span class="log-line-num">${i + 1}</span><span class="log-line-content">${logLinesHtml[i]}</span>`;
845
+ fragment.appendChild(div);
846
+ }
847
+ if (count > 0) logsEl.appendChild(fragment);
848
+ // Update total count
849
+ const total = search
850
+ ? logLinesRaw.filter(l => l.toLowerCase().includes(search)).length
851
+ : logLinesRaw.length;
852
+ updateLogCount(total);
853
+ }
854
+
855
+ function updateLogCount(count: number) {
856
+ const countEl = $('log-line-count');
857
+ if (countEl) countEl.textContent = `${count} line${count !== 1 ? 's' : ''}`;
858
+ }
859
+
476
860
  async function refreshDrawerLogs() {
477
861
  if (!drawerProcess) return;
478
- const logsEl = $('drawer-logs');
862
+ if (drawerTab !== 'stdout' && drawerTab !== 'stderr') return;
863
+ const logsEl = $('drawer-logs') as HTMLElement;
479
864
  if (!logsEl) return;
480
865
 
866
+ // Reset on tab switch
867
+ if (logCurrentTab !== drawerTab) {
868
+ logLinesRaw = [];
869
+ logLinesHtml = [];
870
+ logOffset = 0;
871
+ logCurrentTab = drawerTab;
872
+ logLastSize = -1;
873
+ logNeedsFullRebuild = true;
874
+ }
875
+
481
876
  try {
482
- const res = await fetch(`/api/logs/${encodeURIComponent(drawerProcess)}`);
877
+ const res = await fetch(`/api/logs/${encodeURIComponent(drawerProcess)}?tab=${drawerTab}&offset=${logOffset}`);
483
878
  const data = await res.json();
484
- const text = drawerTab === 'stdout' ? (data.stdout || '') : (data.stderr || '');
485
- const lines = text.split('\n');
879
+ const newText: string = data.text || '';
880
+ const newSize: number = data.size || 0;
486
881
 
487
- if (lines.length === 0 || (lines.length === 1 && !lines[0])) {
488
- logsEl.replaceChildren(
489
- <em style={{ color: 'var(--text-muted)' }}>No logs available</em> as unknown as Node
490
- );
491
- } else {
492
- const logElements = lines.map((line: string) => <LogLine text={line} /> as unknown as Node);
493
- logsEl.replaceChildren(...logElements);
882
+ // ── Fast bail: nothing changed since last poll ──
883
+ if (!newText && newSize === logLastSize && !logNeedsFullRebuild) {
884
+ return; // zero work, zero DOM touches
885
+ }
886
+ logLastSize = newSize;
887
+
888
+ // Update file info bar (lightweight, runs always)
889
+ const infoEl = $('log-file-info');
890
+ if (infoEl) {
891
+ const parts: string[] = [];
892
+ if (data.filePath) {
893
+ parts.push(`<span style="color:var(--text-dim)" title="${data.filePath}">${data.filePath}</span>`);
894
+ }
895
+ if (data.mtime) {
896
+ const ago = formatTimeAgo(new Date(data.mtime));
897
+ parts.push(`<span style="color:var(--text-secondary)">${ago}</span>`);
898
+ }
899
+ infoEl.innerHTML = parts.join(' <span style="color:var(--text-muted)">·</span> ');
900
+ }
901
+
902
+ // ── Append new lines with cached HTML ──
903
+ const prevCount = logLinesRaw.length;
904
+ if (newText) {
905
+ const newLines = newText.split('\n');
906
+ if (logLinesRaw.length > 0 && logOffset > 0 && prevCount > 0) {
907
+ // Merge partial last line
908
+ logLinesRaw[prevCount - 1] += newLines[0];
909
+ logLinesHtml[prevCount - 1] = ansiToHtml(logLinesRaw[prevCount - 1]);
910
+ for (let i = 1; i < newLines.length; i++) {
911
+ logLinesRaw.push(newLines[i]);
912
+ logLinesHtml.push(ansiToHtml(newLines[i]));
913
+ }
914
+ // Need to rebuild first merged line in DOM
915
+ logNeedsFullRebuild = true;
916
+ } else {
917
+ logLinesRaw = newLines;
918
+ logLinesHtml = newLines.map(l => ansiToHtml(l));
919
+ }
920
+ }
921
+
922
+ logOffset = newSize;
923
+
924
+ // ── Render ──
925
+ if (logNeedsFullRebuild) {
926
+ fullRebuildLogs(logsEl);
927
+ } else if (logLinesRaw.length > prevCount) {
928
+ appendNewLogLines(logsEl, prevCount);
929
+ }
930
+ // else: nothing to do
931
+
932
+ if (logAutoScroll) {
933
+ logsEl.scrollTop = logsEl.scrollHeight;
494
934
  }
495
- logsEl.scrollTop = logsEl.scrollHeight;
496
935
  } catch {
497
- logsEl.replaceChildren(
498
- <em style={{ color: 'var(--text-muted)' }}>Failed to load logs</em> as unknown as Node
499
- );
936
+ const logsEl = $('drawer-logs') as HTMLElement;
937
+ if (logsEl) logsEl.innerHTML = '<em style="color: var(--text-muted)">Failed to load logs</em>';
500
938
  }
501
939
  }
502
940
 
503
941
  $('drawer-close-btn')?.addEventListener('click', closeDrawer);
504
942
  backdrop?.addEventListener('click', closeDrawer);
505
943
 
506
- // Tab switching
507
- drawer?.querySelectorAll('.drawer-tab').forEach(tab => {
508
- tab.addEventListener('click', () => {
509
- drawerTab = (tab as HTMLElement).dataset.tab as 'stdout' | 'stderr';
510
- drawer.querySelectorAll('.drawer-tab').forEach(t => {
511
- t.classList.toggle('active', (t as HTMLElement).dataset.tab === drawerTab);
512
- });
944
+ // Auto-scroll toggle — pill button with icon + label
945
+ const autoScrollBtn = $('log-autoscroll-btn');
946
+ function updateAutoScrollBtn() {
947
+ if (!autoScrollBtn) return;
948
+ autoScrollBtn.classList.toggle('active', logAutoScroll);
949
+ // Keep the SVG icon, update the text node
950
+ const svg = autoScrollBtn.querySelector('svg');
951
+ autoScrollBtn.textContent = '';
952
+ if (svg) autoScrollBtn.appendChild(svg);
953
+ autoScrollBtn.appendChild(document.createTextNode(logAutoScroll ? 'Following' : 'Follow'));
954
+ autoScrollBtn.title = logAutoScroll ? 'Auto-scroll: ON — click to pause' : 'Auto-scroll: OFF — click to follow';
955
+ }
956
+ updateAutoScrollBtn(); // Set initial state
957
+
958
+ autoScrollBtn?.addEventListener('click', () => {
959
+ logAutoScroll = !logAutoScroll;
960
+ localStorage.setItem('bgr_autoscroll', String(logAutoScroll));
961
+ updateAutoScrollBtn();
962
+ if (logAutoScroll) {
963
+ const logsEl = $('drawer-logs');
964
+ if (logsEl) logsEl.scrollTop = logsEl.scrollHeight;
965
+ }
966
+ });
967
+
968
+ // Click log line → expand/collapse (word-wrap toggle)
969
+ const logsContainer = $('drawer-logs');
970
+ logsContainer?.addEventListener('click', (e: Event) => {
971
+ const line = (e.target as Element).closest('.log-line') as HTMLElement;
972
+ if (!line) return;
973
+ line.classList.toggle('expanded');
974
+ });
975
+
976
+ // Double-click log line → copy content to clipboard
977
+ logsContainer?.addEventListener('dblclick', (e: Event) => {
978
+ const line = (e.target as Element).closest('.log-line') as HTMLElement;
979
+ if (!line) return;
980
+ const content = line.querySelector('.log-line-content');
981
+ if (!content) return;
982
+ const text = content.textContent || '';
983
+ navigator.clipboard.writeText(text).then(() => {
984
+ line.classList.add('copied');
985
+ setTimeout(() => line.classList.remove('copied'), 1200);
986
+ });
987
+ e.preventDefault();
988
+ });
989
+
990
+ // Log search/filter with debounce
991
+ let logSearchTimeout: ReturnType<typeof setTimeout> | null = null;
992
+ $('log-search')?.addEventListener('input', (e) => {
993
+ if (logSearchTimeout) clearTimeout(logSearchTimeout);
994
+ logSearchTimeout = setTimeout(() => {
995
+ logSearch = (e.target as HTMLInputElement).value;
996
+ logNeedsFullRebuild = true;
513
997
  refreshDrawerLogs();
998
+ }, 200);
999
+ });
1000
+
1001
+ // Accordion section triggers
1002
+ drawer?.querySelectorAll('.accordion-trigger').forEach(trigger => {
1003
+ trigger.addEventListener('click', () => {
1004
+ const section = (trigger as HTMLElement).dataset.section;
1005
+ if (section) openAccordionSection(section);
1006
+ });
1007
+ });
1008
+
1009
+ // Config subtab switching
1010
+ $('config-subtabs')?.querySelectorAll('.accordion-subtab').forEach(btn => {
1011
+ btn.addEventListener('click', () => {
1012
+ const subtab = (btn as HTMLElement).dataset.subtab;
1013
+ if (subtab) switchConfigSubtab(subtab);
1014
+ });
1015
+ });
1016
+
1017
+ // Log subtab switching
1018
+ $('log-subtabs')?.querySelectorAll('.accordion-subtab').forEach(btn => {
1019
+ btn.addEventListener('click', () => {
1020
+ const subtab = (btn as HTMLElement).dataset.subtab;
1021
+ if (subtab) switchLogSubtab(subtab);
514
1022
  });
515
1023
  });
516
1024
 
1025
+ // Config save button
1026
+ $('config-save-btn')?.addEventListener('click', async () => {
1027
+ if (!drawerProcess) return;
1028
+ const editor = $('config-editor') as HTMLTextAreaElement;
1029
+ if (!editor) return;
1030
+ try {
1031
+ const res = await fetch(`/api/config/${encodeURIComponent(drawerProcess)}`, {
1032
+ method: 'PUT',
1033
+ headers: { 'Content-Type': 'application/json' },
1034
+ body: JSON.stringify({ content: editor.value }),
1035
+ });
1036
+ if (res.ok) {
1037
+ showToast(`Config saved for "${drawerProcess}"`, 'success');
1038
+ // Restart the process
1039
+ await fetch(`/api/restart/${encodeURIComponent(drawerProcess)}`, { method: 'POST' });
1040
+ showToast(`Restarted "${drawerProcess}"`, 'success');
1041
+ await loadProcessesFresh();
1042
+ } else {
1043
+ const data = await res.json();
1044
+ showToast(data.error || 'Failed to save config', 'error');
1045
+ }
1046
+ } catch {
1047
+ showToast('Failed to save config', 'error');
1048
+ }
1049
+ });
1050
+
1051
+ // ─── Drawer Resize ───
1052
+
1053
+ const resizeHandle = $('drawer-resize-handle');
1054
+ if (resizeHandle && drawer) {
1055
+ let startX = 0;
1056
+ let startWidth = 0;
1057
+
1058
+ const onMouseMove = (e: MouseEvent) => {
1059
+ const delta = startX - e.clientX;
1060
+ const newWidth = Math.min(Math.max(startWidth + delta, 360), window.innerWidth * 0.85);
1061
+ drawer.style.width = `${newWidth}px`;
1062
+ };
1063
+
1064
+ const onMouseUp = () => {
1065
+ drawer.classList.remove('resizing');
1066
+ document.removeEventListener('mousemove', onMouseMove);
1067
+ document.removeEventListener('mouseup', onMouseUp);
1068
+ // Persist width
1069
+ localStorage.setItem('bgr_drawer_width', drawer.style.width);
1070
+ };
1071
+
1072
+ resizeHandle.addEventListener('mousedown', (e: Event) => {
1073
+ const me = e as MouseEvent;
1074
+ startX = me.clientX;
1075
+ startWidth = drawer.offsetWidth;
1076
+ drawer.classList.add('resizing');
1077
+ document.addEventListener('mousemove', onMouseMove);
1078
+ document.addEventListener('mouseup', onMouseUp);
1079
+ me.preventDefault();
1080
+ });
1081
+
1082
+ // Restore saved width
1083
+ const savedWidth = localStorage.getItem('bgr_drawer_width');
1084
+ if (savedWidth) drawer.style.width = savedWidth;
1085
+ }
1086
+
517
1087
  // ─── New Process Modal ───
518
1088
 
519
1089
  function openModal() {
@@ -580,21 +1150,7 @@ export default function mount(): () => void {
580
1150
  if (drawerProcess) refreshDrawerLogs();
581
1151
  });
582
1152
 
583
- const groupBtn = $('group-toggle-btn');
584
- function updateGroupBtnState() {
585
- if (groupBtn) {
586
- groupBtn.classList.toggle('active', isGrouped);
587
- groupBtn.style.color = isGrouped ? 'var(--accent-primary)' : '';
588
- }
589
- }
590
- updateGroupBtnState();
591
-
592
- groupBtn?.addEventListener('click', () => {
593
- isGrouped = !isGrouped;
594
- localStorage.setItem('bgr_grouped', String(isGrouped));
595
- updateGroupBtnState();
596
- renderFilteredProcesses();
597
- });
1153
+ // Group toggle removed — always-on directory grouping
598
1154
 
599
1155
  // ─── Keyboard Shortcuts ───
600
1156
  function handleKeydown(e: KeyboardEvent) {
@@ -618,10 +1174,39 @@ export default function mount(): () => void {
618
1174
  }
619
1175
  document.addEventListener('keydown', handleKeydown);
620
1176
 
621
- // ─── Auto-Refresh (5s polling, matching server cache TTL) ───
622
- loadProcesses();
623
- refreshTimer = setInterval(() => {
624
- loadProcesses();
1177
+ // ─── SSE Live Updates (replaces polling) ───
1178
+ let eventSource: EventSource | null = null;
1179
+ let logRefreshTimer: ReturnType<typeof setInterval> | null = null;
1180
+ let sseThrottleTimer: ReturnType<typeof setTimeout> | null = null;
1181
+
1182
+ function connectSSE() {
1183
+ eventSource = new EventSource('/api/events');
1184
+ eventSource.onmessage = (event) => {
1185
+ // Skip SSE updates briefly after mutations to avoid flicker
1186
+ if (Date.now() < mutationUntil) return;
1187
+ try {
1188
+ allProcesses = JSON.parse(event.data);
1189
+ // Throttle table re-renders to avoid lag on rapid SSE
1190
+ if (!sseThrottleTimer) {
1191
+ sseThrottleTimer = setTimeout(() => {
1192
+ sseThrottleTimer = null;
1193
+ renderFilteredProcesses();
1194
+ updateStats(allProcesses);
1195
+ }, 2000);
1196
+ }
1197
+ } catch { /* invalid data, skip */ }
1198
+ };
1199
+ eventSource.onerror = () => {
1200
+ // SSE disconnected, reconnect after 5s
1201
+ eventSource?.close();
1202
+ eventSource = null;
1203
+ setTimeout(connectSSE, 5000);
1204
+ };
1205
+ }
1206
+ connectSSE();
1207
+
1208
+ // Log drawer still needs periodic refresh (not part of SSE)
1209
+ logRefreshTimer = setInterval(() => {
625
1210
  if (drawerProcess) refreshDrawerLogs();
626
1211
  }, 5000);
627
1212
 
@@ -635,6 +1220,8 @@ export default function mount(): () => void {
635
1220
  $('modal-create-btn')?.removeEventListener('click', createProcess);
636
1221
  $('refresh-btn')?.removeEventListener('click', loadProcesses);
637
1222
  document.removeEventListener('keydown', handleKeydown);
638
- if (refreshTimer) clearInterval(refreshTimer);
1223
+ if (eventSource) eventSource.close();
1224
+ if (logRefreshTimer) clearInterval(logRefreshTimer);
1225
+ if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
639
1226
  };
640
1227
  }