@irsyadulibad/servermon 1.2.4 → 1.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irsyadulibad/servermon",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "Lightweight server monitoring daemon — collects system metrics and sends structured reports to Telegram. Built with Bun + TypeScript.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -33,6 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@crustjs/core": "^0.0.19",
36
- "@crustjs/plugins": "^0.1.2"
36
+ "@crustjs/plugins": "^0.1.2",
37
+ "@elysiajs/html": "^1.4.2",
38
+ "elysia": "^1.4.29"
37
39
  }
38
40
  }
@@ -0,0 +1,502 @@
1
+ /**
2
+ * servermon dashboard — lightweight web UI for monitoring system metrics.
3
+ *
4
+ * Architecture:
5
+ * startDashboard(port)
6
+ * └─ createDashboardApp(port) ← Elysia routes
7
+ * ├─ GET / ← full page layout
8
+ * ├─ GET /_dashboard/metrics ← HTMX fragment (auto-refresh)
9
+ * └─ GET /_dashboard/health ← JSON health check
10
+ *
11
+ * Templates layer (pure functions, zero Elysia imports):
12
+ * layout() → full HTML page shell
13
+ * metricsFragment → HTMX-swappable dashboard grid
14
+ * cpuCard() → CPU usage card
15
+ * memCard() → RAM usage card
16
+ * diskSection() → disk usage table
17
+ * netCard() → network RX/TX card
18
+ * procSection() → top processes table
19
+ * chartSection() → Chart.js canvas shell
20
+ * headerSection() → host info + temperature row
21
+ *
22
+ * Dependencies: elysia, @elysiajs/html, tailwindcss (CDN), htmx (CDN), chart.js (CDN)
23
+ */
24
+
25
+ import { Elysia } from "elysia";
26
+ import { html } from "@elysiajs/html";
27
+ import { collectMetrics, formatBytes, formatRate, formatUptime } from "../monitor";
28
+ import type { DiskInfo, SystemMetrics } from "../types";
29
+ import pkg from "../../package.json";
30
+
31
+ /* ------------------------------------------------------------------ */
32
+ /* Constants */
33
+ /* ------------------------------------------------------------------ */
34
+
35
+ const APP_VERSION = pkg.version;
36
+ const NET_BAR_SCALE = 10 * 1024 * 1024; // 10 MB/s = 100% bar width
37
+
38
+ /* ------------------------------------------------------------------ */
39
+ /* CSS / Theme */
40
+ /* ------------------------------------------------------------------ */
41
+
42
+ const THEME_SCRIPT = /* js */ `
43
+ tailwindcss.config = {
44
+ darkMode: "class",
45
+ theme: {
46
+ extend: {
47
+ fontFamily: { mono: ['ui-monospace', 'SF Mono', 'Cascadia Code', 'monospace'] },
48
+ },
49
+ },
50
+ };`;
51
+
52
+ const STYLES = /* css */ `
53
+ body { font-family: ui-monospace, "SF Mono", "Cascadia Code", monospace; }
54
+ .progress-bar { transition: width 0.6s ease; }
55
+ .htmx-request { opacity: 0.5; pointer-events: none; }
56
+ .card-fade { transition: opacity 0.3s ease; }
57
+ .glow-green { box-shadow: 0 0 12px rgba(52,211,153,0.15); }
58
+ .glow-blue { box-shadow: 0 0 12px rgba(96,165,250,0.15); }
59
+ .glow-red { box-shadow: 0 0 12px rgba(248,113,113,0.15); }
60
+ @media (prefers-color-scheme: dark) { body { background: #020617; } }`;
61
+
62
+ /* ------------------------------------------------------------------ */
63
+ /* Utility helpers (shared by all template functions) */
64
+ /* ------------------------------------------------------------------ */
65
+
66
+ function esc(s: string): string {
67
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
68
+ }
69
+
70
+ /** Semantic bar colour based on utilisation percentage. */
71
+ function barColor(pct: number, severity: "cpu" | "mem" | "disk" = "disk"): string {
72
+ if (severity === "cpu") return pct > 80 ? "bg-red-500" : pct > 50 ? "bg-yellow-500" : "bg-emerald-500";
73
+ if (severity === "mem") return pct > 80 ? "bg-red-500" : pct > 50 ? "bg-yellow-500" : "bg-blue-500";
74
+ return pct > 90 ? "bg-red-500" : pct > 75 ? "bg-orange-500" : pct > 50 ? "bg-yellow-500" : "bg-blue-500";
75
+ }
76
+
77
+ /** Text colour for percentage label. */
78
+ function pctColor(pct: number): string {
79
+ return pct > 80 ? "text-red-400" : pct > 50 ? "text-yellow-400" : "text-slate-400";
80
+ }
81
+
82
+ /* ------------------------------------------------------------------ */
83
+ /* Template functions (pure, testable, no side-effects) */
84
+ /* ------------------------------------------------------------------ */
85
+
86
+ function headerSection(hostname: string, platform: string, arch: string, uptime: number, temperature: number | null, port: number, version: string): string {
87
+ const tempHtml = temperature != null
88
+ ? `<span class="text-sm font-medium ${temperature > 75 ? 'text-red-400' : temperature > 50 ? 'text-yellow-400' : 'text-green-400'}">${temperature.toFixed(1)}°C</span>`
89
+ : '<span class="text-sm text-slate-500">—°C</span>';
90
+
91
+ const tempIcon = temperature != null
92
+ ? (temperature > 75 ? '🔴' : temperature > 50 ? '🟡' : '🟢')
93
+ : '⚪';
94
+
95
+ return /* html */ `
96
+ <header class="max-w-6xl mx-auto mb-6">
97
+ <div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 bg-slate-900/50 border border-slate-800 rounded-xl p-4">
98
+ <div class="flex items-center gap-3">
99
+ <div class="w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-lg font-bold text-emerald-400 glow-green">◉</div>
100
+ <div>
101
+ <h1 class="text-lg font-bold tracking-tight text-slate-100">
102
+ ServerMon
103
+ <span class="text-xs font-normal text-slate-500 ml-1.5">v${version}</span>
104
+ </h1>
105
+ <p class="text-xs text-slate-400 mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5">
106
+ <span class="font-semibold text-slate-300">${esc(hostname)}</span>
107
+ <span class="text-slate-600">·</span>
108
+ <span>${platform}/${arch}</span>
109
+ <span class="text-slate-600">·</span>
110
+ <span>up ${formatUptime(uptime)}</span>
111
+ <span class="text-slate-600">·</span>
112
+ <span>${tempIcon} ${tempHtml}</span>
113
+ </p>
114
+ </div>
115
+ </div>
116
+ <div class="flex items-center gap-4 text-xs text-slate-500">
117
+ <span class="flex items-center gap-1.5">
118
+ <span class="relative flex h-2.5 w-2.5">
119
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
120
+ <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
121
+ </span>
122
+ live
123
+ </span>
124
+ <span class="hidden sm:inline-flex items-center gap-1.5 bg-slate-800 rounded-md px-2.5 py-1 text-slate-400 font-mono">
125
+ :${port}
126
+ </span>
127
+ </div>
128
+ </div>
129
+ </header>`;
130
+ }
131
+
132
+ function cpuCard(cpu: SystemMetrics["cpu"]): string {
133
+ const pct = cpu.usagePercent;
134
+ const color = barColor(pct, "cpu");
135
+ const glow = pct > 80 ? "glow-red" : pct > 50 ? "" : "glow-green";
136
+ return /* html */ `
137
+ <div class="bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-xl p-4 ${glow} card-fade">
138
+ <div class="flex items-center justify-between mb-3">
139
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">CPU</h3>
140
+ <span class="text-[10px] font-mono text-slate-500">${cpu.cores} cores</span>
141
+ </div>
142
+ <div class="flex items-end gap-2 mb-3">
143
+ <span class="text-4xl font-bold tracking-tight tabular-nums text-slate-100" id="_cpu_val" data-val="${pct}">${pct.toFixed(1)}</span>
144
+ <span class="text-sm text-slate-400 mb-1">%</span>
145
+ </div>
146
+ <div class="w-full h-2.5 bg-slate-700/80 rounded-full mb-3 overflow-hidden">
147
+ <div class="${color} progress-bar h-full rounded-full" style="width:${Math.min(pct, 100)}%"></div>
148
+ </div>
149
+ <div class="text-xs text-slate-500 space-y-0.5">
150
+ <p class="truncate" title="${esc(cpu.model)}">${esc(cpu.model)}</p>
151
+ <p>load: <span class="tabular-nums">${cpu.loadAvg["1min"].toFixed(2)}</span> / <span class="tabular-nums">${cpu.loadAvg["5min"].toFixed(2)}</span> / <span class="tabular-nums">${cpu.loadAvg["15min"].toFixed(2)}</span></p>
152
+ </div>
153
+ </div>`;
154
+ }
155
+
156
+ function memCard(mem: SystemMetrics["memory"]): string {
157
+ const pct = mem.usagePercent;
158
+ const color = barColor(pct, "mem");
159
+ const glow = pct > 80 ? "glow-red" : "";
160
+ return /* html */ `
161
+ <div class="bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-xl p-4 ${glow} card-fade">
162
+ <div class="flex items-center justify-between mb-3">
163
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">Memory</h3>
164
+ <span class="text-[10px] font-mono text-slate-500">${formatBytes(mem.total)}</span>
165
+ </div>
166
+ <div class="flex items-end gap-2 mb-3">
167
+ <span class="text-4xl font-bold tracking-tight tabular-nums text-slate-100" id="_mem_val" data-val="${pct}">${pct.toFixed(1)}</span>
168
+ <span class="text-sm text-slate-400 mb-1">%</span>
169
+ </div>
170
+ <div class="w-full h-2.5 bg-slate-700/80 rounded-full mb-3 overflow-hidden">
171
+ <div class="${color} progress-bar h-full rounded-full" style="width:${Math.min(pct, 100)}%"></div>
172
+ </div>
173
+ <div class="text-xs text-slate-500 space-y-0.5">
174
+ <p><span class="text-slate-300">used</span> ${formatBytes(mem.used)} <span class="text-slate-600">·</span> <span class="text-slate-300">free</span> ${formatBytes(mem.free)}</p>
175
+ ${mem.swapTotal > 0
176
+ ? `<p>swap ${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)} <span class="${pctColor(mem.swapUsagePercent)} tabular-nums">(${mem.swapUsagePercent.toFixed(1)}%)</span></p>`
177
+ : '<p class="text-slate-600">swap n/a</p>'}
178
+ </div>
179
+ </div>`;
180
+ }
181
+
182
+ function diskSection(disks: DiskInfo[]): string {
183
+ if (disks.length === 0) return "";
184
+
185
+ const rows = disks.map((d) => {
186
+ const bar = barColor(d.usagePercent, "disk");
187
+ const txt = pctColor(d.usagePercent);
188
+ return /* html */ `
189
+ <tr class="hover:bg-slate-800/30 transition-colors">
190
+ <td class="py-1.5 pr-3 text-slate-300 font-medium">${esc(d.mount)}</td>
191
+ <td class="py-1.5 pr-3 text-right text-slate-400 text-xs tabular-nums">${formatBytes(d.used)} / ${formatBytes(d.total)}</td>
192
+ <td class="py-1.5 w-36">
193
+ <div class="w-full bg-slate-700/60 rounded-full h-2 overflow-hidden">
194
+ <div class="${bar} progress-bar h-full rounded-full" style="width:${Math.min(d.usagePercent, 100)}%"></div>
195
+ </div>
196
+ </td>
197
+ <td class="py-1.5 pl-2 text-right text-xs font-semibold tabular-nums ${txt}">${d.usagePercent}%</td>
198
+ </tr>`;
199
+ }).join("");
200
+
201
+ return /* html */ `
202
+ <div class="bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-xl p-4 md:col-span-2 card-fade">
203
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Disks</h3>
204
+ <div class="overflow-x-auto">
205
+ <table class="w-full text-xs">
206
+ <thead>
207
+ <tr class="text-slate-500 text-left border-b border-slate-800">
208
+ <th class="pb-2 pr-3 font-medium uppercase tracking-wider text-[10px]">Mount</th>
209
+ <th class="pb-2 pr-3 text-right font-medium uppercase tracking-wider text-[10px]">Usage</th>
210
+ <th class="pb-2 w-36"></th>
211
+ <th class="pb-2 pl-2 text-right font-medium uppercase tracking-wider text-[10px]">%</th>
212
+ </tr>
213
+ </thead>
214
+ <tbody>${rows}</tbody>
215
+ </table>
216
+ </div>
217
+ </div>`;
218
+ }
219
+
220
+ function netCard(net: SystemMetrics["network"]): string {
221
+ const rxWidth = Math.min((net.rxRate / NET_BAR_SCALE) * 100, 100);
222
+ const txWidth = Math.min((net.txRate / NET_BAR_SCALE) * 100, 100);
223
+
224
+ return /* html */ `
225
+ <div class="bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-xl p-4 glow-blue card-fade">
226
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Network</h3>
227
+ <div class="space-y-4">
228
+ <div>
229
+ <div class="flex justify-between text-xs mb-1.5">
230
+ <span class="text-slate-400 flex items-center gap-1.5"><span class="text-emerald-400">↓</span> RX</span>
231
+ <span class="text-emerald-400 font-semibold tabular-nums">${formatRate(net.rxRate)}</span>
232
+ </div>
233
+ <div class="w-full bg-slate-700/60 rounded-full h-1.5 overflow-hidden">
234
+ <div class="bg-emerald-500 h-full rounded-full transition-all duration-500" style="width:${rxWidth}%"></div>
235
+ </div>
236
+ </div>
237
+ <div>
238
+ <div class="flex justify-between text-xs mb-1.5">
239
+ <span class="text-slate-400 flex items-center gap-1.5"><span class="text-blue-400">↑</span> TX</span>
240
+ <span class="text-blue-400 font-semibold tabular-nums">${formatRate(net.txRate)}</span>
241
+ </div>
242
+ <div class="w-full bg-slate-700/60 rounded-full h-1.5 overflow-hidden">
243
+ <div class="bg-blue-500 h-full rounded-full transition-all duration-500" style="width:${txWidth}%"></div>
244
+ </div>
245
+ </div>
246
+ <div class="text-xs text-slate-500 pt-2 border-t border-slate-800 flex justify-between">
247
+ <span>total ↓ ${formatBytes(net.rxTotal)}</span>
248
+ <span>total ↑ ${formatBytes(net.txTotal)}</span>
249
+ </div>
250
+ </div>
251
+ </div>`;
252
+ }
253
+
254
+ function procSection(procs: SystemMetrics["topProcs"]): string {
255
+ if (procs.length === 0) return "";
256
+
257
+ const rows = procs.map((p, i) => {
258
+ const highlight = i === 0 ? "text-yellow-400" : "text-slate-200";
259
+ return /* html */ `
260
+ <tr class="hover:bg-slate-800/30 transition-colors">
261
+ <td class="py-1.5 pr-3 text-slate-500 tabular-nums">${p.pid}</td>
262
+ <td class="py-1.5 pr-3 ${highlight} truncate max-w-[200px]" title="${esc(p.name)}">${esc(p.name)}</td>
263
+ <td class="py-1.5 pr-3 text-right text-slate-300 tabular-nums">${p.cpuPercent.toFixed(1)}%</td>
264
+ <td class="py-1.5 text-right text-slate-300 tabular-nums">${p.memPercent.toFixed(1)}%</td>
265
+ </tr>`;
266
+ }).join("");
267
+
268
+ return /* html */ `
269
+ <div class="bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-xl p-4 md:col-span-2 card-fade">
270
+ <div class="flex items-center justify-between mb-3">
271
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">Top Processes</h3>
272
+ <span class="text-[10px] text-slate-500">by CPU</span>
273
+ </div>
274
+ <div class="overflow-x-auto">
275
+ <table class="w-full text-xs">
276
+ <thead>
277
+ <tr class="text-slate-500 text-left border-b border-slate-800">
278
+ <th class="pb-2 pr-3 font-medium uppercase tracking-wider text-[10px]">PID</th>
279
+ <th class="pb-2 pr-3 font-medium uppercase tracking-wider text-[10px]">Name</th>
280
+ <th class="pb-2 pr-3 text-right font-medium uppercase tracking-wider text-[10px]">CPU</th>
281
+ <th class="pb-2 text-right font-medium uppercase tracking-wider text-[10px]">MEM</th>
282
+ </tr>
283
+ </thead>
284
+ <tbody>${rows}</tbody>
285
+ </table>
286
+ </div>
287
+ </div>`;
288
+ }
289
+
290
+ function chartSection(): string {
291
+ return /* html */ `
292
+ <div class="bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-xl p-4 md:col-span-2 card-fade" id="_chart_wrapper">
293
+ <div class="flex items-center justify-between mb-3">
294
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">CPU & RAM History</h3>
295
+ <span class="text-[10px] text-slate-500">last 60 samples (≈5 min)</span>
296
+ </div>
297
+ <div class="h-52">
298
+ <canvas id="historyChart"></canvas>
299
+ </div>
300
+ </div>`;
301
+ }
302
+
303
+ function footerSection(hostname: string): string {
304
+ return /* html */ `
305
+ <footer class="max-w-6xl mx-auto mt-6 pb-6 text-center text-[10px] text-slate-600 space-y-0.5">
306
+ <p>ServerMon · ${esc(hostname)}</p>
307
+ <p>pressing <kbd class="bg-slate-800 px-1 rounded text-slate-400">Ctrl+C</kbd> in the terminal stops this dashboard</p>
308
+ </footer>`;
309
+ }
310
+
311
+ /** HTMX-swappable dashboard body (replaces #dashboard container). */
312
+ function metricsFragment(metrics: SystemMetrics): string {
313
+ return /* html */ `
314
+ <div id="dashboard" class="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-4"
315
+ hx-get="/_dashboard/metrics" hx-trigger="every 5s" hx-swap="outerHTML" hx-target="#dashboard">
316
+ ${cpuCard(metrics.cpu)}
317
+ ${memCard(metrics.memory)}
318
+ ${diskSection(metrics.disks)}
319
+ ${netCard(metrics.network)}
320
+ ${procSection(metrics.topProcs)}
321
+ ${chartSection()}
322
+ </div>`;
323
+ }
324
+
325
+ /** Full HTML page (served on first load). */
326
+ function layout(metrics: SystemMetrics, port: number, version: string): string {
327
+ const { hostname, platform, arch, uptime, temperature } = metrics;
328
+
329
+ return /* html */ `<!DOCTYPE html>
330
+ <html lang="en" class="dark">
331
+ <head>
332
+ <meta charset="UTF-8" />
333
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
334
+ <title>ServerMon — ${esc(hostname)}</title>
335
+ <script src="https://cdn.tailwindcss.com"></script>
336
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
337
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
338
+ <script>${THEME_SCRIPT}</script>
339
+ <style>${STYLES}</style>
340
+ </head>
341
+ <body class="bg-slate-950 text-slate-100 min-h-screen p-4 md:p-6">
342
+ ${headerSection(hostname, platform, arch, uptime, temperature, port, version)}
343
+ ${metricsFragment(metrics)}
344
+ ${footerSection(hostname)}
345
+
346
+ <script>
347
+ (function() {
348
+ /* ---- Chart.js history (60-sample rolling window) ---- */
349
+ function initChart() {
350
+ const canvas = document.getElementById("historyChart");
351
+ if (!canvas || window._chartInited) return;
352
+ window._chartInited = true;
353
+
354
+ const ctx = canvas.getContext("2d");
355
+ const MAX = 60;
356
+ const data = { cpu: new Array(MAX).fill(0), mem: new Array(MAX).fill(0) };
357
+ const labels = new Array(MAX).fill("");
358
+
359
+ window.__chart = new Chart(ctx, {
360
+ type: "line",
361
+ data: {
362
+ labels: labels,
363
+ datasets: [
364
+ { label: "CPU %", data: data.cpu,
365
+ borderColor: "#34d399", backgroundColor: "rgba(52,211,153,0.06)",
366
+ tension: 0.3, fill: true, pointRadius: 0, borderWidth: 1.5 },
367
+ { label: "RAM %", data: data.mem,
368
+ borderColor: "#60a5fa", backgroundColor: "rgba(96,165,250,0.06)",
369
+ tension: 0.3, fill: true, pointRadius: 0, borderWidth: 1.5 },
370
+ ],
371
+ },
372
+ options: {
373
+ responsive: true, maintainAspectRatio: false, animation: false,
374
+ scales: {
375
+ x: { display: false },
376
+ y: { min: 0, max: 100,
377
+ grid: { color: "rgba(148,163,184,0.06)" },
378
+ ticks: { color: "#64748b", maxTicksLimit: 5 } },
379
+ },
380
+ plugins: {
381
+ legend: { labels: { color: "#94a3b8", boxWidth: 12, padding: 12 } },
382
+ },
383
+ },
384
+ });
385
+
386
+ window.__history = data;
387
+ }
388
+
389
+ /* Push latest values from DOM every 5s (after HTMX swap) */
390
+ function pushMetrics() {
391
+ const cpuEl = document.getElementById("_cpu_val");
392
+ const memEl = document.getElementById("_mem_val");
393
+ if (!cpuEl || !memEl || !window.__history || !window.__chart) return;
394
+
395
+ const cpu = parseFloat(cpuEl.dataset.val) || 0;
396
+ const mem = parseFloat(memEl.dataset.val) || 0;
397
+
398
+ window.__history.cpu.push(cpu);
399
+ window.__history.mem.push(mem);
400
+ if (window.__history.cpu.length > 60) {
401
+ window.__history.cpu.shift();
402
+ window.__history.mem.shift();
403
+ }
404
+ window.__chart.update("none");
405
+ }
406
+
407
+ /* Init on first load */
408
+ document.addEventListener("DOMContentLoaded", initChart);
409
+ /* Re-init after HTMX swap (chart canvas is replaced) */
410
+ document.body.addEventListener("htmx:afterSwap", function() {
411
+ window._chartInited = false;
412
+ initChart();
413
+ /* Push a value immediately after swap so chart isn't all zeros */
414
+ setTimeout(pushMetrics, 100);
415
+ });
416
+ /* Periodic push */
417
+ setInterval(pushMetrics, 5000);
418
+ })();
419
+ </script>
420
+ </body>
421
+ </html>`;
422
+ }
423
+
424
+ /* ------------------------------------------------------------------ */
425
+ /* Application layer */
426
+ /* ------------------------------------------------------------------ */
427
+
428
+ async function getMetrics(): Promise<SystemMetrics> {
429
+ const m = await collectMetrics();
430
+ if (m.cpu.usagePercent > 100) m.cpu.usagePercent = 100;
431
+ return m;
432
+ }
433
+
434
+ function createDashboardApp(port: number): Elysia {
435
+ return new Elysia()
436
+ .use(html())
437
+ .get("/", async () => {
438
+ try {
439
+ const metrics = await getMetrics();
440
+ return layout(metrics, port, APP_VERSION);
441
+ } catch (err) {
442
+ const msg = err instanceof Error ? err.message : String(err);
443
+ return layoutError(`Failed to collect metrics: ${esc(msg)}`);
444
+ }
445
+ })
446
+ .get("/_dashboard/metrics", async () => {
447
+ try {
448
+ const metrics = await getMetrics();
449
+ return metricsFragment(metrics);
450
+ } catch {
451
+ return `<div id="dashboard" class="max-w-6xl mx-auto text-center py-12">
452
+ <p class="text-red-400">⚠️ Failed to refresh metrics. Retrying in 5s…</p>
453
+ </div>`;
454
+ }
455
+ })
456
+ .get("/_dashboard/health", () => ({
457
+ status: "ok",
458
+ uptime: Math.floor(process.uptime()),
459
+ version: APP_VERSION,
460
+ }));
461
+ }
462
+
463
+ function layoutError(message: string): string {
464
+ return /* html */ `<!DOCTYPE html>
465
+ <html lang="en" class="dark">
466
+ <head><meta charset="UTF-8"><title>ServerMon — Error</title>
467
+ <script src="https://cdn.tailwindcss.com"></script></head>
468
+ <body class="bg-slate-950 text-slate-100 min-h-screen flex items-center justify-center p-8">
469
+ <div class="bg-slate-900 border border-red-800/50 rounded-xl p-8 max-w-lg text-center">
470
+ <div class="text-4xl mb-4">⚠️</div>
471
+ <h1 class="text-lg font-bold mb-2">Dashboard Error</h1>
472
+ <p class="text-sm text-slate-400 mb-4">${message}</p>
473
+ <a href="/" class="text-sm text-emerald-400 hover:underline">Retry</a>
474
+ </div>
475
+ </body></html>`;
476
+ }
477
+
478
+ /* ------------------------------------------------------------------ */
479
+ /* Public entry point */
480
+ /* ------------------------------------------------------------------ */
481
+
482
+ /**
483
+ * Start the embedded web dashboard.
484
+ *
485
+ * Usage: `servermon dashboard --port 3456`
486
+ *
487
+ * Opens an HTMX + Elysia dashboard showing real-time system metrics.
488
+ */
489
+ export async function startDashboard(port: number): Promise<void> {
490
+ const app = createDashboardApp(port);
491
+
492
+ console.log();
493
+ console.log(` 🌐 Dashboard starting at http://localhost:${port}`);
494
+ console.log(` ⏱ Auto-refresh every 5s (HTMX + Chart.js)`);
495
+ console.log(` ⌨ Press Ctrl+C to stop`);
496
+ console.log();
497
+
498
+ app.listen(port, () => {
499
+ console.log(` ✅ ServerMon Dashboard — http://localhost:${port}`);
500
+ console.log();
501
+ });
502
+ }
package/src/cli/index.ts CHANGED
@@ -237,6 +237,24 @@ export function createApp(): Crust {
237
237
  })
238
238
  )
239
239
 
240
+ /* ---- dashboard ---- */
241
+ .command("dashboard", (cmd) =>
242
+ cmd
243
+ .meta({ description: "Start embedded web dashboard (HTMX + Elysia)" })
244
+ .flags({
245
+ port: {
246
+ type: "number",
247
+ description: "HTTP port (default: 3456)",
248
+ short: "p",
249
+ default: 3456,
250
+ },
251
+ })
252
+ .run(async ({ flags }) => {
253
+ const { startDashboard } = await import("./dashboard");
254
+ await startDashboard(flags.port ?? 3456);
255
+ })
256
+ )
257
+
240
258
  /* ---- service ---- */
241
259
  .command("service", (cmd) => serviceCmd(cmd))
242
260