bgrun 3.3.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 +720 -0
- package/dashboard/app/api/logs/[name]/route.ts +17 -0
- package/dashboard/app/api/processes/[name]/route.ts +19 -0
- package/dashboard/app/api/processes/route.ts +150 -0
- package/dashboard/app/api/restart/[name]/route.ts +20 -0
- package/dashboard/app/api/start/route.ts +22 -0
- package/dashboard/app/api/stop/[name]/route.ts +16 -0
- package/dashboard/app/api/version/route.ts +8 -0
- package/dashboard/app/globals.css +1135 -0
- package/dashboard/app/layout.tsx +47 -0
- package/dashboard/app/page.client.tsx +554 -0
- package/dashboard/app/page.tsx +130 -0
- package/dist/index.js +1580 -0
- package/examples/bgr-startup.sh +40 -0
- package/package.json +60 -0
- package/src/api.ts +31 -0
- package/src/build.ts +26 -0
- package/src/commands/cleanup.ts +142 -0
- package/src/commands/details.ts +46 -0
- package/src/commands/list.ts +86 -0
- package/src/commands/logs.ts +49 -0
- package/src/commands/run.ts +151 -0
- package/src/commands/watch.ts +223 -0
- package/src/config.ts +37 -0
- package/src/db.ts +115 -0
- package/src/index.ts +349 -0
- package/src/logger.ts +29 -0
- package/src/platform.ts +440 -0
- package/src/schema.ts +2 -0
- package/src/server.ts +24 -0
- package/src/table.ts +230 -0
- package/src/types.ts +27 -0
- package/src/utils.ts +99 -0
- package/src/version.macro.ts +17 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bgrun Dashboard — Root Layout (Server Component)
|
|
3
|
+
*
|
|
4
|
+
* Renders the page shell: header with logo, version badge, and refresh button.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export default function RootLayout({ children }: { children: any }) {
|
|
8
|
+
return (
|
|
9
|
+
<html lang="en">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charSet="utf-8" />
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
13
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
14
|
+
<title>bgrun Dashboard</title>
|
|
15
|
+
<meta name="description" content="bgrun — Bun Background Runner — Process Manager Dashboard" />
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div className="container">
|
|
19
|
+
<header className="header">
|
|
20
|
+
<div className="logo">
|
|
21
|
+
<div className="logo-icon">⚡</div>
|
|
22
|
+
<div>
|
|
23
|
+
<h1>bgrun</h1>
|
|
24
|
+
<span className="logo-subtitle">Bun Background Runner</span>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<div className="header-actions">
|
|
28
|
+
<span className="version-badge" id="version-badge">...</span>
|
|
29
|
+
<button className="btn btn-ghost" id="refresh-btn">
|
|
30
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
31
|
+
<polyline points="23 4 23 10 17 10" />
|
|
32
|
+
<polyline points="1 20 1 14 7 14" />
|
|
33
|
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
|
34
|
+
</svg>
|
|
35
|
+
Refresh
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
</header>
|
|
39
|
+
|
|
40
|
+
<main id="melina-page-content">
|
|
41
|
+
{children}
|
|
42
|
+
</main>
|
|
43
|
+
</div>
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bgrun Dashboard — Page Client Interactivity
|
|
3
|
+
*
|
|
4
|
+
* NOT a React component. A mount function that adds interactivity
|
|
5
|
+
* to the server-rendered HTML. JSX here creates real DOM elements
|
|
6
|
+
* (via Melina's jsx-dom runtime, not React virtual DOM).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface ProcessData {
|
|
10
|
+
name: string;
|
|
11
|
+
command: string;
|
|
12
|
+
directory: string;
|
|
13
|
+
pid: number;
|
|
14
|
+
running: boolean;
|
|
15
|
+
port: number | null;
|
|
16
|
+
ports: number[];
|
|
17
|
+
runtime: string;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── SVG Icon Helpers ───
|
|
22
|
+
|
|
23
|
+
function SvgIcon({ d, className }: { d: string; className?: string }) {
|
|
24
|
+
return (
|
|
25
|
+
<svg className={className || ''} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
26
|
+
<path d={d} />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function LogsIcon() {
|
|
32
|
+
return (
|
|
33
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
34
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
35
|
+
<polyline points="14 2 14 8 20 8" />
|
|
36
|
+
<line x1="16" y1="13" x2="8" y2="13" />
|
|
37
|
+
<line x1="16" y1="17" x2="8" y2="17" />
|
|
38
|
+
</svg>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function StopIcon() {
|
|
43
|
+
return (
|
|
44
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
45
|
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
46
|
+
</svg>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function PlayIcon() {
|
|
51
|
+
return (
|
|
52
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
53
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function TrashIcon() {
|
|
59
|
+
return (
|
|
60
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
61
|
+
<polyline points="3 6 5 6 21 6" />
|
|
62
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
63
|
+
</svg>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function RestartIcon() {
|
|
68
|
+
return (
|
|
69
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
70
|
+
<polyline points="23 4 23 10 17 10" />
|
|
71
|
+
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Utility: Format Runtime ───
|
|
77
|
+
|
|
78
|
+
function formatRuntime(raw: string): string {
|
|
79
|
+
// raw is like "386 minutes" or "21 minutes" or "0 minutes"
|
|
80
|
+
const match = raw?.match(/(\d+)\s*minute/i);
|
|
81
|
+
if (!match) return raw || '-';
|
|
82
|
+
|
|
83
|
+
const totalMinutes = parseInt(match[1]);
|
|
84
|
+
if (totalMinutes <= 0) return '<1m';
|
|
85
|
+
if (totalMinutes < 60) return `${totalMinutes}m`;
|
|
86
|
+
|
|
87
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
88
|
+
const mins = totalMinutes % 60;
|
|
89
|
+
if (hours < 24) {
|
|
90
|
+
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
91
|
+
}
|
|
92
|
+
const days = Math.floor(hours / 24);
|
|
93
|
+
const remainHours = hours % 24;
|
|
94
|
+
return remainHours > 0 ? `${days}d ${remainHours}h` : `${days}d`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── JSX Components ───
|
|
98
|
+
|
|
99
|
+
function ProcessRow({ p, animate }: { p: ProcessData; animate?: boolean }) {
|
|
100
|
+
return (
|
|
101
|
+
<tr data-process-name={p.name} className={animate ? 'animate-in' : ''} style={animate ? { opacity: '0' } : undefined}>
|
|
102
|
+
<td>
|
|
103
|
+
<div className="process-name">
|
|
104
|
+
<div className="process-icon">{p.name.charAt(0).toUpperCase()}</div>
|
|
105
|
+
<span>{p.name}</span>
|
|
106
|
+
</div>
|
|
107
|
+
</td>
|
|
108
|
+
<td>
|
|
109
|
+
<span className={`status-badge ${p.running ? 'running' : 'stopped'}`}>
|
|
110
|
+
<span className="status-dot"></span>
|
|
111
|
+
{p.running ? 'Running' : 'Stopped'}
|
|
112
|
+
</span>
|
|
113
|
+
</td>
|
|
114
|
+
<td className="pid">{String(p.pid)}</td>
|
|
115
|
+
<td>
|
|
116
|
+
{p.port
|
|
117
|
+
? <span className="port-num">:{p.port}</span>
|
|
118
|
+
: <span style={{ color: 'var(--text-muted)' }}>–</span>
|
|
119
|
+
}
|
|
120
|
+
</td>
|
|
121
|
+
<td className="command" title={p.command}>{p.command}</td>
|
|
122
|
+
<td className="runtime">{formatRuntime(p.runtime)}</td>
|
|
123
|
+
<td className="actions">
|
|
124
|
+
<button className="action-btn info" data-action="logs" data-name={p.name} title="View Logs">
|
|
125
|
+
<LogsIcon />
|
|
126
|
+
</button>
|
|
127
|
+
{p.running
|
|
128
|
+
? <button className="action-btn danger" data-action="stop" data-name={p.name} title="Stop">
|
|
129
|
+
<StopIcon />
|
|
130
|
+
</button>
|
|
131
|
+
: <button className="action-btn success" data-action="restart" data-name={p.name} title="Start">
|
|
132
|
+
<PlayIcon />
|
|
133
|
+
</button>
|
|
134
|
+
}
|
|
135
|
+
<button className="action-btn danger" data-action="delete" data-name={p.name} title="Delete">
|
|
136
|
+
<TrashIcon />
|
|
137
|
+
</button>
|
|
138
|
+
</td>
|
|
139
|
+
</tr>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function EmptyState() {
|
|
144
|
+
return (
|
|
145
|
+
<tr>
|
|
146
|
+
<td colSpan={7}>
|
|
147
|
+
<div className="empty-state">
|
|
148
|
+
<div className="empty-icon">📦</div>
|
|
149
|
+
<h3>No processes found</h3>
|
|
150
|
+
<p>Start a new process to see it here</p>
|
|
151
|
+
</div>
|
|
152
|
+
</td>
|
|
153
|
+
</tr>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function LogLine({ text }: { text: string }) {
|
|
158
|
+
return <div className="log-line">{text}</div>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Toast System ───
|
|
162
|
+
|
|
163
|
+
function showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
|
|
164
|
+
const container = document.getElementById('toast-container');
|
|
165
|
+
if (!container) return;
|
|
166
|
+
|
|
167
|
+
const icons: Record<string, string> = { success: '✓', error: '✕', info: 'i' };
|
|
168
|
+
|
|
169
|
+
const toast = (
|
|
170
|
+
<div className={`toast ${type}`}>
|
|
171
|
+
<div className="toast-icon">{icons[type]}</div>
|
|
172
|
+
<span>{message}</span>
|
|
173
|
+
</div>
|
|
174
|
+
) as unknown as HTMLElement;
|
|
175
|
+
|
|
176
|
+
container.appendChild(toast);
|
|
177
|
+
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
toast.classList.add('removing');
|
|
180
|
+
setTimeout(() => toast.remove(), 250);
|
|
181
|
+
}, 3000);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Mount Function ───
|
|
185
|
+
|
|
186
|
+
export default function mount(): () => void {
|
|
187
|
+
const $ = (id: string) => document.getElementById(id);
|
|
188
|
+
let selectedProcess: string | null = null;
|
|
189
|
+
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
190
|
+
let isFetching = false;
|
|
191
|
+
let isFirstLoad = true;
|
|
192
|
+
let allProcesses: ProcessData[] = [];
|
|
193
|
+
let searchQuery = '';
|
|
194
|
+
let drawerProcess: string | null = null;
|
|
195
|
+
let drawerTab: 'stdout' | 'stderr' = 'stdout';
|
|
196
|
+
|
|
197
|
+
// ─── Version Badge ───
|
|
198
|
+
const versionBadge = $('version-badge');
|
|
199
|
+
async function loadVersion() {
|
|
200
|
+
if (!versionBadge) return;
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch('/api/version');
|
|
203
|
+
const data = await res.json();
|
|
204
|
+
versionBadge.textContent = data.version ? `v${data.version}` : 'bgrun';
|
|
205
|
+
} catch {
|
|
206
|
+
versionBadge.textContent = 'bgrun';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
loadVersion();
|
|
210
|
+
|
|
211
|
+
// ─── Load & Render Processes ───
|
|
212
|
+
|
|
213
|
+
async function loadProcesses() {
|
|
214
|
+
if (isFetching) return;
|
|
215
|
+
isFetching = true;
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch('/api/processes');
|
|
218
|
+
allProcesses = await res.json();
|
|
219
|
+
renderFilteredProcesses();
|
|
220
|
+
updateStats(allProcesses);
|
|
221
|
+
} catch {
|
|
222
|
+
// silently retry on next tick
|
|
223
|
+
} finally {
|
|
224
|
+
isFetching = false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderFilteredProcesses() {
|
|
229
|
+
const filtered = searchQuery
|
|
230
|
+
? allProcesses.filter(p =>
|
|
231
|
+
p.name.toLowerCase().includes(searchQuery) ||
|
|
232
|
+
p.command.toLowerCase().includes(searchQuery) ||
|
|
233
|
+
(p.port && String(p.port).includes(searchQuery))
|
|
234
|
+
)
|
|
235
|
+
: allProcesses;
|
|
236
|
+
renderProcesses(filtered);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function updateStats(processes: ProcessData[]) {
|
|
240
|
+
const total = processes.length;
|
|
241
|
+
const running = processes.filter(p => p.running).length;
|
|
242
|
+
const stopped = total - running;
|
|
243
|
+
|
|
244
|
+
const tc = $('total-count');
|
|
245
|
+
const rc = $('running-count');
|
|
246
|
+
const sc = $('stopped-count');
|
|
247
|
+
if (tc) tc.textContent = String(total);
|
|
248
|
+
if (rc) rc.textContent = String(running);
|
|
249
|
+
if (sc) sc.textContent = String(stopped);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function renderProcesses(processes: ProcessData[]) {
|
|
253
|
+
const tbody = $('processes-table');
|
|
254
|
+
if (!tbody) return;
|
|
255
|
+
|
|
256
|
+
if (processes.length === 0) {
|
|
257
|
+
tbody.replaceChildren(<EmptyState /> as unknown as Node);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const animate = isFirstLoad;
|
|
262
|
+
const rows = processes.map(p => <ProcessRow p={p} animate={animate} /> as unknown as Node);
|
|
263
|
+
tbody.replaceChildren(...rows);
|
|
264
|
+
|
|
265
|
+
if (isFirstLoad) isFirstLoad = false;
|
|
266
|
+
|
|
267
|
+
// Highlight selected row
|
|
268
|
+
if (drawerProcess) {
|
|
269
|
+
const row = tbody.querySelector(`tr[data-process-name="${drawerProcess}"]`);
|
|
270
|
+
if (row) row.classList.add('selected');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Search ───
|
|
275
|
+
|
|
276
|
+
const searchInput = $('search-input') as HTMLInputElement;
|
|
277
|
+
searchInput?.addEventListener('input', () => {
|
|
278
|
+
searchQuery = searchInput.value.toLowerCase().trim();
|
|
279
|
+
renderFilteredProcesses();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ─── Action Handlers ───
|
|
283
|
+
|
|
284
|
+
async function handleAction(e: Event) {
|
|
285
|
+
const btn = (e.target as Element).closest('[data-action]') as HTMLElement;
|
|
286
|
+
if (!btn) return;
|
|
287
|
+
|
|
288
|
+
const action = btn.dataset.action;
|
|
289
|
+
const name = btn.dataset.name;
|
|
290
|
+
if (!name) return;
|
|
291
|
+
|
|
292
|
+
switch (action) {
|
|
293
|
+
case 'stop':
|
|
294
|
+
try {
|
|
295
|
+
await fetch(`/api/stop/${encodeURIComponent(name)}`, { method: 'POST' });
|
|
296
|
+
showToast(`Stopped "${name}"`, 'success');
|
|
297
|
+
await loadProcesses();
|
|
298
|
+
} catch (err: any) {
|
|
299
|
+
showToast(`Failed to stop "${name}"`, 'error');
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
|
|
303
|
+
case 'restart':
|
|
304
|
+
try {
|
|
305
|
+
await fetch(`/api/restart/${encodeURIComponent(name)}`, { method: 'POST' });
|
|
306
|
+
showToast(`Restarted "${name}"`, 'success');
|
|
307
|
+
await loadProcesses();
|
|
308
|
+
} catch (err: any) {
|
|
309
|
+
showToast(`Failed to restart "${name}"`, 'error');
|
|
310
|
+
}
|
|
311
|
+
break;
|
|
312
|
+
|
|
313
|
+
case 'delete':
|
|
314
|
+
try {
|
|
315
|
+
await fetch(`/api/processes/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
316
|
+
showToast(`Deleted "${name}"`, 'success');
|
|
317
|
+
if (drawerProcess === name) closeDrawer();
|
|
318
|
+
await loadProcesses();
|
|
319
|
+
} catch (err: any) {
|
|
320
|
+
showToast(`Failed to delete "${name}"`, 'error');
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
|
|
324
|
+
case 'logs':
|
|
325
|
+
openDrawer(name);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const tbody = $('processes-table');
|
|
331
|
+
|
|
332
|
+
// Row click → open drawer
|
|
333
|
+
tbody?.addEventListener('click', (e: Event) => {
|
|
334
|
+
const btn = (e.target as Element).closest('[data-action]');
|
|
335
|
+
if (btn) {
|
|
336
|
+
handleAction(e);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const row = (e.target as Element).closest('tr[data-process-name]') as HTMLElement;
|
|
340
|
+
if (row && row.dataset.processName) {
|
|
341
|
+
openDrawer(row.dataset.processName);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ─── Detail Drawer ───
|
|
346
|
+
|
|
347
|
+
const drawer = $('detail-drawer');
|
|
348
|
+
const backdrop = $('drawer-backdrop');
|
|
349
|
+
|
|
350
|
+
function openDrawer(name: string) {
|
|
351
|
+
drawerProcess = name;
|
|
352
|
+
drawerTab = 'stdout';
|
|
353
|
+
|
|
354
|
+
// Update header
|
|
355
|
+
const nameEl = $('drawer-process-name');
|
|
356
|
+
const iconEl = $('drawer-icon');
|
|
357
|
+
if (nameEl) nameEl.textContent = name;
|
|
358
|
+
if (iconEl) iconEl.textContent = name.charAt(0).toUpperCase();
|
|
359
|
+
|
|
360
|
+
// Update meta info
|
|
361
|
+
const proc = allProcesses.find(p => p.name === name);
|
|
362
|
+
const meta = $('drawer-meta');
|
|
363
|
+
if (meta && proc) {
|
|
364
|
+
const metaItems = [
|
|
365
|
+
{ label: 'Status', value: proc.running ? '● Running' : '○ Stopped' },
|
|
366
|
+
{ label: 'PID', value: String(proc.pid) },
|
|
367
|
+
{ label: 'Port', value: proc.port ? `:${proc.port}` : '–' },
|
|
368
|
+
{ label: 'Runtime', value: formatRuntime(proc.runtime) },
|
|
369
|
+
{ label: 'Command', value: proc.command },
|
|
370
|
+
{ label: 'Directory', value: proc.directory || '–' },
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
const items = metaItems.map(m => (
|
|
374
|
+
<div className="meta-item">
|
|
375
|
+
<span className="meta-label">{m.label}</span>
|
|
376
|
+
<span className="meta-value">{m.value}</span>
|
|
377
|
+
</div>
|
|
378
|
+
) as unknown as Node);
|
|
379
|
+
meta.replaceChildren(...items);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Update tab state
|
|
383
|
+
drawer?.querySelectorAll('.drawer-tab').forEach(tab => {
|
|
384
|
+
tab.classList.toggle('active', (tab as HTMLElement).dataset.tab === drawerTab);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Show drawer
|
|
388
|
+
drawer?.classList.add('open');
|
|
389
|
+
backdrop?.classList.add('active');
|
|
390
|
+
|
|
391
|
+
// Highlight table row
|
|
392
|
+
tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
|
|
393
|
+
const row = tbody?.querySelector(`tr[data-process-name="${name}"]`);
|
|
394
|
+
if (row) row.classList.add('selected');
|
|
395
|
+
|
|
396
|
+
refreshDrawerLogs();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function closeDrawer() {
|
|
400
|
+
drawer?.classList.remove('open');
|
|
401
|
+
backdrop?.classList.remove('active');
|
|
402
|
+
drawerProcess = null;
|
|
403
|
+
tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function refreshDrawerLogs() {
|
|
407
|
+
if (!drawerProcess) return;
|
|
408
|
+
const logsEl = $('drawer-logs');
|
|
409
|
+
if (!logsEl) return;
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const res = await fetch(`/api/logs/${encodeURIComponent(drawerProcess)}`);
|
|
413
|
+
const data = await res.json();
|
|
414
|
+
const text = drawerTab === 'stdout' ? (data.stdout || '') : (data.stderr || '');
|
|
415
|
+
const lines = text.split('\n');
|
|
416
|
+
|
|
417
|
+
if (lines.length === 0 || (lines.length === 1 && !lines[0])) {
|
|
418
|
+
logsEl.replaceChildren(
|
|
419
|
+
<em style={{ color: 'var(--text-muted)' }}>No logs available</em> as unknown as Node
|
|
420
|
+
);
|
|
421
|
+
} else {
|
|
422
|
+
const logElements = lines.map((line: string) => <LogLine text={line} /> as unknown as Node);
|
|
423
|
+
logsEl.replaceChildren(...logElements);
|
|
424
|
+
}
|
|
425
|
+
logsEl.scrollTop = logsEl.scrollHeight;
|
|
426
|
+
} catch {
|
|
427
|
+
logsEl.replaceChildren(
|
|
428
|
+
<em style={{ color: 'var(--text-muted)' }}>Failed to load logs</em> as unknown as Node
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
$('drawer-close-btn')?.addEventListener('click', closeDrawer);
|
|
434
|
+
backdrop?.addEventListener('click', closeDrawer);
|
|
435
|
+
|
|
436
|
+
// Tab switching
|
|
437
|
+
drawer?.querySelectorAll('.drawer-tab').forEach(tab => {
|
|
438
|
+
tab.addEventListener('click', () => {
|
|
439
|
+
drawerTab = (tab as HTMLElement).dataset.tab as 'stdout' | 'stderr';
|
|
440
|
+
drawer.querySelectorAll('.drawer-tab').forEach(t => {
|
|
441
|
+
t.classList.toggle('active', (t as HTMLElement).dataset.tab === drawerTab);
|
|
442
|
+
});
|
|
443
|
+
refreshDrawerLogs();
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ─── New Process Modal ───
|
|
448
|
+
|
|
449
|
+
function openModal() {
|
|
450
|
+
const modal = $('new-process-modal');
|
|
451
|
+
if (modal) modal.classList.add('active');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function closeModal() {
|
|
455
|
+
const modal = $('new-process-modal');
|
|
456
|
+
if (modal) modal.classList.remove('active');
|
|
457
|
+
const nameInput = $('process-name-input') as HTMLInputElement;
|
|
458
|
+
const cmdInput = $('process-command-input') as HTMLInputElement;
|
|
459
|
+
const dirInput = $('process-directory-input') as HTMLInputElement;
|
|
460
|
+
if (nameInput) nameInput.value = '';
|
|
461
|
+
if (cmdInput) cmdInput.value = '';
|
|
462
|
+
if (dirInput) dirInput.value = '';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function createProcess() {
|
|
466
|
+
const name = ($('process-name-input') as HTMLInputElement)?.value?.trim();
|
|
467
|
+
const command = ($('process-command-input') as HTMLInputElement)?.value?.trim();
|
|
468
|
+
const directory = ($('process-directory-input') as HTMLInputElement)?.value?.trim();
|
|
469
|
+
|
|
470
|
+
if (!name || !command || !directory) {
|
|
471
|
+
showToast('Please fill in all fields', 'error');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const res = await fetch('/api/start', {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
headers: { 'Content-Type': 'application/json' },
|
|
479
|
+
body: JSON.stringify({ name, command, directory }),
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
if (res.ok) {
|
|
483
|
+
closeModal();
|
|
484
|
+
showToast(`Created "${name}"`, 'success');
|
|
485
|
+
await loadProcesses();
|
|
486
|
+
} else {
|
|
487
|
+
const data = await res.json();
|
|
488
|
+
showToast(data.error || 'Failed to create process', 'error');
|
|
489
|
+
}
|
|
490
|
+
} catch (err: any) {
|
|
491
|
+
showToast('Failed to create process', 'error');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
$('new-process-btn')?.addEventListener('click', openModal);
|
|
496
|
+
$('modal-close-btn')?.addEventListener('click', closeModal);
|
|
497
|
+
$('modal-cancel-btn')?.addEventListener('click', closeModal);
|
|
498
|
+
$('modal-create-btn')?.addEventListener('click', createProcess);
|
|
499
|
+
|
|
500
|
+
// Close modal on overlay click
|
|
501
|
+
$('new-process-modal')?.addEventListener('click', (e) => {
|
|
502
|
+
if ((e.target as Element).classList.contains('modal-overlay')) {
|
|
503
|
+
closeModal();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// ─── Refresh Button ───
|
|
508
|
+
$('refresh-btn')?.addEventListener('click', () => {
|
|
509
|
+
loadProcesses();
|
|
510
|
+
if (drawerProcess) refreshDrawerLogs();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// ─── Keyboard Shortcuts ───
|
|
514
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
515
|
+
// "/" to focus search (unless already in an input)
|
|
516
|
+
if (e.key === '/' && !(e.target instanceof HTMLInputElement)) {
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
searchInput?.focus();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (e.key === 'Escape') {
|
|
522
|
+
if (drawer?.classList.contains('open')) {
|
|
523
|
+
closeDrawer();
|
|
524
|
+
} else {
|
|
525
|
+
closeModal();
|
|
526
|
+
}
|
|
527
|
+
// Blur search on escape
|
|
528
|
+
if (document.activeElement === searchInput) {
|
|
529
|
+
searchInput?.blur();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
document.addEventListener('keydown', handleKeydown);
|
|
534
|
+
|
|
535
|
+
// ─── Auto-Refresh (5s polling, matching server cache TTL) ───
|
|
536
|
+
loadProcesses();
|
|
537
|
+
refreshTimer = setInterval(() => {
|
|
538
|
+
loadProcesses();
|
|
539
|
+
if (drawerProcess) refreshDrawerLogs();
|
|
540
|
+
}, 5000);
|
|
541
|
+
|
|
542
|
+
// ─── Cleanup ───
|
|
543
|
+
return () => {
|
|
544
|
+
$('drawer-close-btn')?.removeEventListener('click', closeDrawer);
|
|
545
|
+
backdrop?.removeEventListener('click', closeDrawer);
|
|
546
|
+
$('new-process-btn')?.removeEventListener('click', openModal);
|
|
547
|
+
$('modal-close-btn')?.removeEventListener('click', closeModal);
|
|
548
|
+
$('modal-cancel-btn')?.removeEventListener('click', closeModal);
|
|
549
|
+
$('modal-create-btn')?.removeEventListener('click', createProcess);
|
|
550
|
+
$('refresh-btn')?.removeEventListener('click', loadProcesses);
|
|
551
|
+
document.removeEventListener('keydown', handleKeydown);
|
|
552
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
553
|
+
};
|
|
554
|
+
}
|