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.
- package/README.md +26 -3
- package/dashboard/app/api/config/[name]/route.ts +55 -0
- package/dashboard/app/api/debug/route.ts +3 -2
- package/dashboard/app/api/events/route.ts +60 -0
- package/dashboard/app/api/logs/[name]/route.ts +55 -5
- package/dashboard/app/api/processes/[name]/route.ts +5 -2
- package/dashboard/app/api/processes/route.ts +88 -44
- package/dashboard/app/api/restart/[name]/route.ts +4 -3
- package/dashboard/app/api/start/route.ts +4 -3
- package/dashboard/app/api/stop/[name]/route.ts +11 -4
- package/dashboard/app/api/version/route.ts +4 -2
- package/dashboard/app/globals.css +657 -87
- package/dashboard/app/layout.tsx +2 -23
- package/dashboard/app/page.client.tsx +703 -116
- package/dashboard/app/page.tsx +97 -33
- package/dist/index.js +394 -224
- package/image.png +0 -0
- package/package.json +60 -56
- package/src/api.ts +13 -0
- package/src/commands/list.ts +1 -0
- package/src/commands/run.ts +60 -47
- package/src/db.ts +27 -5
- package/src/index.ts +75 -3
- package/src/platform.ts +202 -96
- package/src/server.ts +2 -1
- package/src/types.ts +2 -2
|
@@ -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:
|
|
16
|
-
|
|
17
|
-
memory: number;
|
|
18
|
-
|
|
19
|
-
|
|
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=
|
|
195
|
+
<tr className={`group-header ${collapsed ? 'collapsed' : ''}`} data-group-name={name}>
|
|
161
196
|
<td colSpan={8}>
|
|
162
|
-
<div className="group-label">
|
|
163
|
-
<
|
|
164
|
-
{
|
|
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
|
|
186
|
-
return
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
478
|
+
localStorage.setItem('bgr_collapsed_groups', JSON.stringify([...collapsedGroups]));
|
|
479
|
+
renderFilteredProcesses();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
325
482
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
const
|
|
879
|
+
const newText: string = data.text || '';
|
|
880
|
+
const newSize: number = data.size || 0;
|
|
486
881
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
498
|
-
|
|
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
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
// ───
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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 (
|
|
1223
|
+
if (eventSource) eventSource.close();
|
|
1224
|
+
if (logRefreshTimer) clearInterval(logRefreshTimer);
|
|
1225
|
+
if (sseThrottleTimer) clearTimeout(sseThrottleTimer);
|
|
639
1226
|
};
|
|
640
1227
|
}
|