@irsyadulibad/servermon 1.2.4 → 1.2.6

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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "permissions": {
3
+ "allow": ["Bash(bunx tsc *)"]
4
+ }
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irsyadulibad/servermon",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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,681 @@
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
+ .card-fade { transition: opacity 0.3s ease; }
56
+ .glow-green { box-shadow: 0 0 12px rgba(52,211,153,0.15); }
57
+ .glow-blue { box-shadow: 0 0 12px rgba(96,165,250,0.15); }
58
+ .glow-red { box-shadow: 0 0 12px rgba(248,113,113,0.15); }
59
+ @media (prefers-color-scheme: dark) { body { background: #020617; } }`;
60
+
61
+ /* ------------------------------------------------------------------ */
62
+ /* Utility helpers (shared by all template functions) */
63
+ /* ------------------------------------------------------------------ */
64
+
65
+ function esc(s: string): string {
66
+ return s
67
+ .replace(/&/g, "&")
68
+ .replace(/</g, "&lt;")
69
+ .replace(/>/g, "&gt;")
70
+ .replace(/"/g, "&quot;");
71
+ }
72
+
73
+ /** Semantic bar colour based on utilisation percentage. */
74
+ function barColor(pct: number, severity: "cpu" | "mem" | "disk" = "disk"): string {
75
+ if (severity === "cpu")
76
+ return pct > 80 ? "bg-red-500" : pct > 50 ? "bg-yellow-500" : "bg-emerald-500";
77
+ if (severity === "mem")
78
+ return pct > 80 ? "bg-red-500" : pct > 50 ? "bg-yellow-500" : "bg-blue-500";
79
+ return pct > 90
80
+ ? "bg-red-500"
81
+ : pct > 75
82
+ ? "bg-orange-500"
83
+ : pct > 50
84
+ ? "bg-yellow-500"
85
+ : "bg-blue-500";
86
+ }
87
+
88
+ /** Text colour for percentage label. */
89
+ function pctColor(pct: number): string {
90
+ return pct > 80 ? "text-red-400" : pct > 50 ? "text-yellow-400" : "text-slate-400";
91
+ }
92
+
93
+ /* ------------------------------------------------------------------ */
94
+ /* Template functions (pure, testable, no side-effects) */
95
+ /* ------------------------------------------------------------------ */
96
+
97
+ function headerSection(
98
+ hostname: string,
99
+ platform: string,
100
+ arch: string,
101
+ uptime: number,
102
+ temperature: number | null,
103
+ port: number,
104
+ version: string
105
+ ): string {
106
+ const tempHtml =
107
+ temperature != null
108
+ ? `<span class="text-sm font-medium ${temperature > 75 ? "text-red-400" : temperature > 50 ? "text-yellow-400" : "text-green-400"}">${temperature.toFixed(1)}°C</span>`
109
+ : '<span class="text-sm text-slate-500">—°C</span>';
110
+
111
+ const tempIcon =
112
+ temperature != null ? (temperature > 75 ? "🔴" : temperature > 50 ? "🟡" : "🟢") : "⚪";
113
+
114
+ return /* html */ `
115
+ <header class="max-w-6xl mx-auto mb-6">
116
+ <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">
117
+ <div class="flex items-center gap-3">
118
+ <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>
119
+ <div>
120
+ <h1 class="text-lg font-bold tracking-tight text-slate-100">
121
+ ServerMon
122
+ <span class="text-xs font-normal text-slate-500 ml-1.5">v${version}</span>
123
+ </h1>
124
+ <p class="text-xs text-slate-400 mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5">
125
+ <span class="font-semibold text-slate-300">${esc(hostname)}</span>
126
+ <span class="text-slate-600">·</span>
127
+ <span>${platform}/${arch}</span>
128
+ <span class="text-slate-600">·</span>
129
+ <span>up ${formatUptime(uptime)}</span>
130
+ <span class="text-slate-600">·</span>
131
+ <span>${tempIcon} ${tempHtml}</span>
132
+ </p>
133
+ </div>
134
+ </div>
135
+ <div class="flex items-center gap-4 text-xs text-slate-500">
136
+ <span class="flex items-center gap-1.5">
137
+ <span class="relative flex h-2.5 w-2.5">
138
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
139
+ <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
140
+ </span>
141
+ live
142
+ </span>
143
+ <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">
144
+ :${port}
145
+ </span>
146
+ </div>
147
+ </div>
148
+ </header>`;
149
+ }
150
+
151
+ function cpuCard(cpu: SystemMetrics["cpu"]): string {
152
+ const pct = cpu.usagePercent;
153
+ const color = barColor(pct, "cpu");
154
+ const glow = pct > 80 ? "glow-red" : pct > 50 ? "" : "glow-green";
155
+ return /* html */ `
156
+ <div class="bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-xl p-4 lg:col-span-2 ${glow} card-fade">
157
+ <div class="flex items-center justify-between mb-3">
158
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">CPU</h3>
159
+ <span class="text-[10px] font-mono text-slate-500">${cpu.cores} cores</span>
160
+ </div>
161
+ <div class="flex items-end gap-2 mb-3">
162
+ <span class="text-4xl font-bold tracking-tight tabular-nums text-slate-100" id="_cpu_val" data-val="${pct}">${pct.toFixed(1)}</span>
163
+ <span class="text-sm text-slate-400 mb-1">%</span>
164
+ </div>
165
+ <div class="w-full h-2.5 bg-slate-700/80 rounded-full mb-3 overflow-hidden">
166
+ <div class="${color} progress-bar h-full rounded-full" style="width:${Math.min(pct, 100)}%"></div>
167
+ </div>
168
+ <div class="text-xs text-slate-500 space-y-0.5">
169
+ <p class="truncate" title="${esc(cpu.model)}">${esc(cpu.model)}</p>
170
+ <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>
171
+ </div>
172
+ </div>`;
173
+ }
174
+
175
+ function memCard(mem: SystemMetrics["memory"]): string {
176
+ const pct = mem.usagePercent;
177
+ const color = barColor(pct, "mem");
178
+ const glow = pct > 80 ? "glow-red" : "";
179
+ return /* html */ `
180
+ <div class="bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-xl p-4 lg:col-span-2 ${glow} card-fade">
181
+ <div class="flex items-center justify-between mb-3">
182
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">Memory</h3>
183
+ <span class="text-[10px] font-mono text-slate-500">${formatBytes(mem.total)}</span>
184
+ </div>
185
+ <div class="flex items-end gap-2 mb-3">
186
+ <span class="text-4xl font-bold tracking-tight tabular-nums text-slate-100" id="_mem_val" data-val="${pct}">${pct.toFixed(1)}</span>
187
+ <span class="text-sm text-slate-400 mb-1">%</span>
188
+ </div>
189
+ <div class="w-full h-2.5 bg-slate-700/80 rounded-full mb-3 overflow-hidden">
190
+ <div class="${color} progress-bar h-full rounded-full" style="width:${Math.min(pct, 100)}%"></div>
191
+ </div>
192
+ <div class="text-xs text-slate-500 space-y-0.5">
193
+ <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>
194
+ ${
195
+ mem.swapTotal > 0
196
+ ? `<p>swap ${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)} <span class="${pctColor(mem.swapUsagePercent)} tabular-nums">(${mem.swapUsagePercent.toFixed(1)}%)</span></p>`
197
+ : '<p class="text-slate-600">swap n/a</p>'
198
+ }
199
+ </div>
200
+ </div>`;
201
+ }
202
+
203
+ function diskSection(disks: DiskInfo[]): string {
204
+ if (disks.length === 0) return "";
205
+
206
+ const rows = disks
207
+ .map((d) => {
208
+ const bar = barColor(d.usagePercent, "disk");
209
+ const txt = pctColor(d.usagePercent);
210
+ return /* html */ `
211
+ <tr class="hover:bg-slate-800/30 transition-colors">
212
+ <td class="py-1.5 pr-3 text-slate-300 font-medium">${esc(d.mount)}</td>
213
+ <td class="py-1.5 pr-3 text-right text-slate-400 text-xs tabular-nums">${formatBytes(d.used)} / ${formatBytes(d.total)}</td>
214
+ <td class="py-1.5 w-36">
215
+ <div class="w-full bg-slate-700/60 rounded-full h-2 overflow-hidden">
216
+ <div class="${bar} progress-bar h-full rounded-full" style="width:${Math.min(d.usagePercent, 100)}%"></div>
217
+ </div>
218
+ </td>
219
+ <td class="py-1.5 pl-2 text-right text-xs font-semibold tabular-nums ${txt}">${d.usagePercent}%</td>
220
+ </tr>`;
221
+ })
222
+ .join("");
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 md:col-span-2 lg:col-span-3 card-fade">
226
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Disks</h3>
227
+ <div class="overflow-x-auto">
228
+ <table class="w-full text-xs">
229
+ <thead>
230
+ <tr class="text-slate-500 text-left border-b border-slate-800">
231
+ <th class="pb-2 pr-3 font-medium uppercase tracking-wider text-[10px]">Mount</th>
232
+ <th class="pb-2 pr-3 text-right font-medium uppercase tracking-wider text-[10px]">Usage</th>
233
+ <th class="pb-2 w-36"></th>
234
+ <th class="pb-2 pl-2 text-right font-medium uppercase tracking-wider text-[10px]">%</th>
235
+ </tr>
236
+ </thead>
237
+ <tbody>${rows}</tbody>
238
+ </table>
239
+ </div>
240
+ </div>`;
241
+ }
242
+
243
+ function netCard(net: SystemMetrics["network"]): string {
244
+ const rxWidth = Math.min((net.rxRate / NET_BAR_SCALE) * 100, 100);
245
+ const txWidth = Math.min((net.txRate / NET_BAR_SCALE) * 100, 100);
246
+
247
+ return /* html */ `
248
+ <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 lg:col-span-2 glow-blue card-fade">
249
+ <span id="_rx_val" data-val="${net.rxRate}" class="hidden"></span>
250
+ <span id="_tx_val" data-val="${net.txRate}" class="hidden"></span>
251
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Network</h3>
252
+ <div class="space-y-4">
253
+ <div>
254
+ <div class="flex justify-between text-xs mb-1.5">
255
+ <span class="text-slate-400 flex items-center gap-1.5"><span class="text-emerald-400">↓</span> RX</span>
256
+ <span class="text-emerald-400 font-semibold tabular-nums">${formatRate(net.rxRate)}</span>
257
+ </div>
258
+ <div class="w-full bg-slate-700/60 rounded-full h-1.5 overflow-hidden">
259
+ <div class="bg-emerald-500 h-full rounded-full transition-all duration-500" style="width:${rxWidth}%"></div>
260
+ </div>
261
+ </div>
262
+ <div>
263
+ <div class="flex justify-between text-xs mb-1.5">
264
+ <span class="text-slate-400 flex items-center gap-1.5"><span class="text-blue-400">↑</span> TX</span>
265
+ <span class="text-blue-400 font-semibold tabular-nums">${formatRate(net.txRate)}</span>
266
+ </div>
267
+ <div class="w-full bg-slate-700/60 rounded-full h-1.5 overflow-hidden">
268
+ <div class="bg-blue-500 h-full rounded-full transition-all duration-500" style="width:${txWidth}%"></div>
269
+ </div>
270
+ </div>
271
+ <div class="text-xs text-slate-500 pt-2 border-t border-slate-800 flex justify-between">
272
+ <span>total ↓ ${formatBytes(net.rxTotal)}</span>
273
+ <span>total ↑ ${formatBytes(net.txTotal)}</span>
274
+ </div>
275
+ </div>
276
+ </div>`;
277
+ }
278
+
279
+ function procSection(procs: SystemMetrics["topProcs"]): string {
280
+ if (procs.length === 0) return "";
281
+
282
+ const rows = procs
283
+ .map((p, i) => {
284
+ const highlight = i === 0 ? "text-yellow-400" : "text-slate-200";
285
+ return /* html */ `
286
+ <tr class="hover:bg-slate-800/30 transition-colors">
287
+ <td class="py-1.5 pr-3 text-slate-500 tabular-nums">${p.pid}</td>
288
+ <td class="py-1.5 pr-3 ${highlight} truncate max-w-[200px]" title="${esc(p.name)}">${esc(p.name)}</td>
289
+ <td class="py-1.5 pr-3 text-right text-slate-300 tabular-nums">${p.cpuPercent.toFixed(1)}%</td>
290
+ <td class="py-1.5 text-right text-slate-300 tabular-nums">${p.memPercent.toFixed(1)}%</td>
291
+ </tr>`;
292
+ })
293
+ .join("");
294
+
295
+ return /* html */ `
296
+ <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 lg:col-span-3 card-fade">
297
+ <div class="flex items-center justify-between mb-3">
298
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">Top Processes</h3>
299
+ <span class="text-[10px] text-slate-500">by CPU</span>
300
+ </div>
301
+ <div class="overflow-x-auto">
302
+ <table class="w-full text-xs">
303
+ <thead>
304
+ <tr class="text-slate-500 text-left border-b border-slate-800">
305
+ <th class="pb-2 pr-3 font-medium uppercase tracking-wider text-[10px]">PID</th>
306
+ <th class="pb-2 pr-3 font-medium uppercase tracking-wider text-[10px]">Name</th>
307
+ <th class="pb-2 pr-3 text-right font-medium uppercase tracking-wider text-[10px]">CPU</th>
308
+ <th class="pb-2 text-right font-medium uppercase tracking-wider text-[10px]">MEM</th>
309
+ </tr>
310
+ </thead>
311
+ <tbody>${rows}</tbody>
312
+ </table>
313
+ </div>
314
+ </div>`;
315
+ }
316
+
317
+ function chartSection(): string {
318
+ return /* html */ `
319
+ <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 lg:col-span-6 card-fade" id="_chart_wrapper">
320
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-4">
321
+ <div>
322
+ <div class="flex items-center justify-between mb-3">
323
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">CPU &amp; RAM</h3>
324
+ <div class="flex items-center gap-3 text-[10px] text-slate-500">
325
+ <span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full bg-emerald-400"></span>CPU</span>
326
+ <span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full bg-blue-400"></span>RAM</span>
327
+ </div>
328
+ </div>
329
+ <div class="h-48">
330
+ <canvas id="historyChart"></canvas>
331
+ </div>
332
+ </div>
333
+ <div>
334
+ <div class="flex items-center justify-between mb-3">
335
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400">Network I/O</h3>
336
+ <div class="flex items-center gap-3 text-[10px] text-slate-500">
337
+ <span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full bg-emerald-400"></span>RX</span>
338
+ <span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full bg-violet-400"></span>TX</span>
339
+ </div>
340
+ </div>
341
+ <div class="h-48">
342
+ <canvas id="netChart"></canvas>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ <p class="text-[10px] text-slate-600 text-right mt-2">60 samples · ≈5 min rolling window</p>
347
+ </div>`;
348
+ }
349
+
350
+ function footerSection(hostname: string): string {
351
+ return /* html */ `
352
+ <footer class="max-w-6xl mx-auto mt-6 pb-6 text-center text-[10px] text-slate-600 space-y-0.5">
353
+ <p>ServerMon · ${esc(hostname)}</p>
354
+ <p>pressing <kbd class="bg-slate-800 px-1 rounded text-slate-400">Ctrl+C</kbd> in the terminal stops this dashboard</p>
355
+ </footer>`;
356
+ }
357
+
358
+ /** HTMX-swappable dashboard body (replaces #dashboard container). */
359
+ function metricsFragment(metrics: SystemMetrics): string {
360
+ return /* html */ `
361
+ <div id="dashboard" class="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4"
362
+ hx-get="/_dashboard/metrics" hx-trigger="every 5s" hx-swap="outerHTML" hx-target="#dashboard">
363
+ ${cpuCard(metrics.cpu)}
364
+ ${memCard(metrics.memory)}
365
+ ${netCard(metrics.network)}
366
+ ${chartSection()}
367
+ ${diskSection(metrics.disks)}
368
+ ${procSection(metrics.topProcs)}
369
+ </div>`;
370
+ }
371
+
372
+ /** Full HTML page (served on first load). */
373
+ function layout(metrics: SystemMetrics, port: number, version: string): string {
374
+ const { hostname, platform, arch, uptime, temperature } = metrics;
375
+
376
+ return /* html */ `<!DOCTYPE html>
377
+ <html lang="en" class="dark">
378
+ <head>
379
+ <meta charset="UTF-8" />
380
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
381
+ <title>ServerMon — ${esc(hostname)}</title>
382
+ <script src="https://cdn.tailwindcss.com"></script>
383
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
384
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
385
+ <script>${THEME_SCRIPT}</script>
386
+ <style>${STYLES}</style>
387
+ </head>
388
+ <body class="bg-slate-950 text-slate-100 min-h-screen p-4 md:p-6">
389
+ ${headerSection(hostname, platform, arch, uptime, temperature, port, version)}
390
+ ${metricsFragment(metrics)}
391
+ ${footerSection(hostname)}
392
+
393
+ <script>
394
+ (function() {
395
+ const MAX = 60;
396
+
397
+ function makeGradient(ctx, colorTop, colorBot) {
398
+ const g = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight || 160);
399
+ g.addColorStop(0, colorTop);
400
+ g.addColorStop(1, colorBot);
401
+ return g;
402
+ }
403
+
404
+ function baseOptions(yLabel, yMax) {
405
+ return {
406
+ responsive: true,
407
+ maintainAspectRatio: false,
408
+ animation: { duration: 400, easing: "easeOutQuart" },
409
+ interaction: { mode: "index", intersect: false },
410
+ scales: {
411
+ x: {
412
+ display: true,
413
+ grid: { display: false },
414
+ ticks: {
415
+ color: "#475569",
416
+ maxTicksLimit: 7,
417
+ maxRotation: 0,
418
+ callback: function(val, i) {
419
+ const lbl = this.chart.data.labels[i];
420
+ return lbl || "";
421
+ },
422
+ },
423
+ border: { display: false },
424
+ },
425
+ y: {
426
+ min: 0, max: yMax,
427
+ grid: { color: "rgba(148,163,184,0.05)", drawBorder: false },
428
+ ticks: {
429
+ color: "#475569",
430
+ maxTicksLimit: 5,
431
+ callback: (v) => yLabel === "%" ? v + "%" : v,
432
+ },
433
+ border: { display: false },
434
+ },
435
+ },
436
+ plugins: {
437
+ legend: { display: false },
438
+ tooltip: {
439
+ backgroundColor: "rgba(15,23,42,0.95)",
440
+ borderColor: "rgba(148,163,184,0.12)",
441
+ borderWidth: 1,
442
+ titleColor: "#94a3b8",
443
+ bodyColor: "#e2e8f0",
444
+ padding: 10,
445
+ callbacks: {
446
+ label: function(ctx) {
447
+ const val = ctx.parsed.y;
448
+ if (yLabel === "%") return " " + ctx.dataset.label + ": " + val.toFixed(1) + "%";
449
+ return " " + ctx.dataset.label + ": " + formatBytesRate(val);
450
+ },
451
+ },
452
+ },
453
+ },
454
+ };
455
+ }
456
+
457
+ function formatBytesRate(bps) {
458
+ if (bps >= 1048576) return (bps / 1048576).toFixed(1) + " MB/s";
459
+ if (bps >= 1024) return (bps / 1024).toFixed(1) + " KB/s";
460
+ return bps.toFixed(0) + " B/s";
461
+ }
462
+
463
+ function nowLabel() {
464
+ const d = new Date();
465
+ return d.getHours().toString().padStart(2,"0") + ":" + d.getMinutes().toString().padStart(2,"0") + ":" + d.getSeconds().toString().padStart(2,"0");
466
+ }
467
+
468
+ function initCharts() {
469
+ const canvas = document.getElementById("historyChart");
470
+ const netCanvas = document.getElementById("netChart");
471
+ if (!canvas || window._chartInited) return;
472
+ window._chartInited = true;
473
+
474
+ /* Destroy stale chart instances bound to the now-replaced canvases. */
475
+ if (window.__chart) { window.__chart.destroy(); window.__chart = null; }
476
+ if (window.__netChart) { window.__netChart.destroy(); window.__netChart = null; }
477
+
478
+ /* -- history data store (preserve across HTMX swaps) -- */
479
+ const history = window.__history || {
480
+ labels: new Array(MAX).fill(""),
481
+ cpu: new Array(MAX).fill(null),
482
+ mem: new Array(MAX).fill(null),
483
+ rx: new Array(MAX).fill(null),
484
+ tx: new Array(MAX).fill(null),
485
+ };
486
+ window.__history = history;
487
+
488
+ /* -- CPU/RAM chart -- */
489
+ const ctx = canvas.getContext("2d");
490
+ const cpuGrad = makeGradient(ctx, "rgba(52,211,153,0.25)", "rgba(52,211,153,0.01)");
491
+ const memGrad = makeGradient(ctx, "rgba(96,165,250,0.25)", "rgba(96,165,250,0.01)");
492
+
493
+ window.__chart = new Chart(ctx, {
494
+ type: "line",
495
+ data: {
496
+ labels: history.labels,
497
+ datasets: [
498
+ {
499
+ label: "CPU",
500
+ data: history.cpu,
501
+ borderColor: "#34d399",
502
+ backgroundColor: cpuGrad,
503
+ tension: 0.4, fill: true, pointRadius: 0,
504
+ borderWidth: 2,
505
+ spanGaps: false,
506
+ },
507
+ {
508
+ label: "RAM",
509
+ data: history.mem,
510
+ borderColor: "#60a5fa",
511
+ backgroundColor: memGrad,
512
+ tension: 0.4, fill: true, pointRadius: 0,
513
+ borderWidth: 2,
514
+ spanGaps: false,
515
+ },
516
+ ],
517
+ },
518
+ options: baseOptions("%", 100),
519
+ });
520
+
521
+ /* -- Network chart -- */
522
+ if (netCanvas) {
523
+ const nctx = netCanvas.getContext("2d");
524
+ const rxGrad = makeGradient(nctx, "rgba(52,211,153,0.20)", "rgba(52,211,153,0.01)");
525
+ const txGrad = makeGradient(nctx, "rgba(167,139,250,0.20)", "rgba(167,139,250,0.01)");
526
+
527
+ window.__netChart = new Chart(nctx, {
528
+ type: "line",
529
+ data: {
530
+ labels: history.labels,
531
+ datasets: [
532
+ {
533
+ label: "RX",
534
+ data: history.rx,
535
+ borderColor: "#34d399",
536
+ backgroundColor: rxGrad,
537
+ tension: 0.4, fill: true, pointRadius: 0,
538
+ borderWidth: 1.5,
539
+ spanGaps: false,
540
+ },
541
+ {
542
+ label: "TX",
543
+ data: history.tx,
544
+ borderColor: "#a78bfa",
545
+ backgroundColor: txGrad,
546
+ tension: 0.4, fill: true, pointRadius: 0,
547
+ borderWidth: 1.5,
548
+ spanGaps: false,
549
+ },
550
+ ],
551
+ },
552
+ options: (function() {
553
+ const o = baseOptions("bytes", undefined);
554
+ o.scales.y.ticks.callback = (v) => formatBytesRate(v);
555
+ return o;
556
+ })(),
557
+ });
558
+ }
559
+ }
560
+
561
+ function pushMetrics() {
562
+ const cpuEl = document.getElementById("_cpu_val");
563
+ const memEl = document.getElementById("_mem_val");
564
+ const rxEl = document.getElementById("_rx_val");
565
+ const txEl = document.getElementById("_tx_val");
566
+ if (!cpuEl || !memEl || !window.__history) return;
567
+
568
+ const h = window.__history;
569
+ const label = nowLabel();
570
+
571
+ h.labels.push(label);
572
+ h.cpu.push(parseFloat(cpuEl.dataset.val) || 0);
573
+ h.mem.push(parseFloat(memEl.dataset.val) || 0);
574
+ h.rx.push(rxEl ? (parseFloat(rxEl.dataset.val) || 0) : null);
575
+ h.tx.push(txEl ? (parseFloat(txEl.dataset.val) || 0) : null);
576
+
577
+ if (h.labels.length > MAX) { h.labels.shift(); h.cpu.shift(); h.mem.shift(); h.rx.shift(); h.tx.shift(); }
578
+
579
+ if (window.__chart) window.__chart.update();
580
+ if (window.__netChart) window.__netChart.update();
581
+ }
582
+
583
+ /* First load: build charts and seed the first sample. */
584
+ document.addEventListener("DOMContentLoaded", function() {
585
+ initCharts();
586
+ setTimeout(pushMetrics, 200);
587
+ });
588
+
589
+ /* Each HTMX refresh replaces the canvas — rebuild on the preserved
590
+ history and append exactly one fresh sample. This is the only push
591
+ path, so samples map 1:1 to the 5s refresh (no stale/duplicate data). */
592
+ document.body.addEventListener("htmx:afterSwap", function() {
593
+ window._chartInited = false;
594
+ initCharts();
595
+ setTimeout(pushMetrics, 100);
596
+ });
597
+ })();
598
+ </script>
599
+ </body>
600
+ </html>`;
601
+ }
602
+
603
+ /* ------------------------------------------------------------------ */
604
+ /* Application layer */
605
+ /* ------------------------------------------------------------------ */
606
+
607
+ async function getMetrics(): Promise<SystemMetrics> {
608
+ const m = await collectMetrics();
609
+ if (m.cpu.usagePercent > 100) m.cpu.usagePercent = 100;
610
+ return m;
611
+ }
612
+
613
+ function createDashboardApp(port: number): Elysia {
614
+ return new Elysia()
615
+ .use(html())
616
+ .get("/", async () => {
617
+ try {
618
+ const metrics = await getMetrics();
619
+ return layout(metrics, port, APP_VERSION);
620
+ } catch (err) {
621
+ const msg = err instanceof Error ? err.message : String(err);
622
+ return layoutError(`Failed to collect metrics: ${esc(msg)}`);
623
+ }
624
+ })
625
+ .get("/_dashboard/metrics", async () => {
626
+ try {
627
+ const metrics = await getMetrics();
628
+ return metricsFragment(metrics);
629
+ } catch {
630
+ return `<div id="dashboard" class="max-w-6xl mx-auto text-center py-12">
631
+ <p class="text-red-400">⚠️ Failed to refresh metrics. Retrying in 5s…</p>
632
+ </div>`;
633
+ }
634
+ })
635
+ .get("/_dashboard/health", () => ({
636
+ status: "ok",
637
+ uptime: Math.floor(process.uptime()),
638
+ version: APP_VERSION,
639
+ }));
640
+ }
641
+
642
+ function layoutError(message: string): string {
643
+ return /* html */ `<!DOCTYPE html>
644
+ <html lang="en" class="dark">
645
+ <head><meta charset="UTF-8"><title>ServerMon — Error</title>
646
+ <script src="https://cdn.tailwindcss.com"></script></head>
647
+ <body class="bg-slate-950 text-slate-100 min-h-screen flex items-center justify-center p-8">
648
+ <div class="bg-slate-900 border border-red-800/50 rounded-xl p-8 max-w-lg text-center">
649
+ <div class="text-4xl mb-4">⚠️</div>
650
+ <h1 class="text-lg font-bold mb-2">Dashboard Error</h1>
651
+ <p class="text-sm text-slate-400 mb-4">${message}</p>
652
+ <a href="/" class="text-sm text-emerald-400 hover:underline">Retry</a>
653
+ </div>
654
+ </body></html>`;
655
+ }
656
+
657
+ /* ------------------------------------------------------------------ */
658
+ /* Public entry point */
659
+ /* ------------------------------------------------------------------ */
660
+
661
+ /**
662
+ * Start the embedded web dashboard.
663
+ *
664
+ * Usage: `servermon dashboard --port 3456`
665
+ *
666
+ * Opens an HTMX + Elysia dashboard showing real-time system metrics.
667
+ */
668
+ export async function startDashboard(port: number): Promise<void> {
669
+ const app = createDashboardApp(port);
670
+
671
+ console.log();
672
+ console.log(` 🌐 Dashboard starting at http://localhost:${port}`);
673
+ console.log(` ⏱ Auto-refresh every 5s (HTMX + Chart.js)`);
674
+ console.log(` ⌨ Press Ctrl+C to stop`);
675
+ console.log();
676
+
677
+ app.listen(port, () => {
678
+ console.log(` ✅ ServerMon Dashboard — http://localhost:${port}`);
679
+ console.log();
680
+ });
681
+ }
package/src/cli/index.ts CHANGED
@@ -1,12 +1,6 @@
1
1
  import { Crust } from "@crustjs/core";
2
2
  import { helpPlugin, versionPlugin, didYouMeanPlugin } from "@crustjs/plugins";
3
- import {
4
- loadConfig,
5
- saveConfig,
6
- configPath,
7
- listServers,
8
- deleteConfig,
9
- } from "../config";
3
+ import { loadConfig, saveConfig, configPath, listServers, deleteConfig } from "../config";
10
4
  import { printBanner } from "./banner";
11
5
  import { serviceCmd } from "./service";
12
6
  import pkg from "../../package.json";
@@ -103,38 +97,36 @@ export function createApp(): Crust {
103
97
  )
104
98
  /* ---- start ---- */
105
99
  .command("start", (cmd) =>
106
- cmd
107
- .meta({ description: "Start the monitoring daemon" })
108
- .run(async ({ flags }) => {
109
- const name = flags.name;
100
+ cmd.meta({ description: "Start the monitoring daemon" }).run(async ({ flags }) => {
101
+ const name = flags.name;
110
102
 
111
- if (name) {
112
- const config = await loadConfig(name);
113
- if (!config) {
114
- console.error(
115
- `❌ No config found for server "${name}". Run \`servermon setup --name ${name}\` first.`
116
- );
117
- process.exit(1);
118
- }
119
- process.env["TELEGRAM_BOT_TOKEN"] = config.token;
120
- process.env["MONITOR_INTERVAL"] = String(config.interval);
121
- if (config.chatId) process.env["TELEGRAM_CHAT_ID"] = config.chatId;
122
- if (name) process.env["SERVER_NAME"] = name;
103
+ if (name) {
104
+ const config = await loadConfig(name);
105
+ if (!config) {
106
+ console.error(
107
+ `❌ No config found for server "${name}". Run \`servermon setup --name ${name}\` first.`
108
+ );
109
+ process.exit(1);
110
+ }
111
+ process.env["TELEGRAM_BOT_TOKEN"] = config.token;
112
+ process.env["MONITOR_INTERVAL"] = String(config.interval);
113
+ if (config.chatId) process.env["TELEGRAM_CHAT_ID"] = config.chatId;
114
+ if (name) process.env["SERVER_NAME"] = name;
123
115
 
124
- console.log(`📁 Config: ${configPath()}`);
125
- console.log(`📡 Bot: ...${config.token.slice(-8)}`);
126
- if (config.chatId) console.log(`💬 Chat: ${config.chatId}`);
127
- if (name) console.log(`🏷 Name: ${name}`);
128
- console.log();
116
+ console.log(`📁 Config: ${configPath()}`);
117
+ console.log(`📡 Bot: ...${config.token.slice(-8)}`);
118
+ if (config.chatId) console.log(`💬 Chat: ${config.chatId}`);
119
+ if (name) console.log(`🏷 Name: ${name}`);
120
+ console.log();
129
121
 
130
- const { start } = await import("../daemon");
131
- await start();
132
- } else {
133
- console.log(" 🌐 Multi-server mode — monitoring all configured servers\n");
134
- const { startAll } = await import("../daemon");
135
- await startAll();
136
- }
137
- })
122
+ const { start } = await import("../daemon");
123
+ await start();
124
+ } else {
125
+ console.log(" 🌐 Multi-server mode — monitoring all configured servers\n");
126
+ const { startAll } = await import("../daemon");
127
+ await startAll();
128
+ }
129
+ })
138
130
  )
139
131
 
140
132
  /* ---- report ---- */
@@ -237,6 +229,24 @@ export function createApp(): Crust {
237
229
  })
238
230
  )
239
231
 
232
+ /* ---- dashboard ---- */
233
+ .command("dashboard", (cmd) =>
234
+ cmd
235
+ .meta({ description: "Start embedded web dashboard (HTMX + Elysia)" })
236
+ .flags({
237
+ port: {
238
+ type: "number",
239
+ description: "HTTP port (default: 3456)",
240
+ short: "p",
241
+ default: 3456,
242
+ },
243
+ })
244
+ .run(async ({ flags }) => {
245
+ const { startDashboard } = await import("./dashboard");
246
+ await startDashboard(flags.port ?? 3456);
247
+ })
248
+ )
249
+
240
250
  /* ---- service ---- */
241
251
  .command("service", (cmd) => serviceCmd(cmd))
242
252
 
@@ -56,7 +56,9 @@ function getServiceStatus(): string {
56
56
  async function cmdInstall(): Promise<void> {
57
57
  const servers = await listServers();
58
58
  if (servers.length === 0) {
59
- console.error("❌ No servers configured. Run `servermon setup` or `servermon setup --name <name>` first.");
59
+ console.error(
60
+ "❌ No servers configured. Run `servermon setup` or `servermon setup --name <name>` first."
61
+ );
60
62
  process.exit(1);
61
63
  }
62
64
 
@@ -196,39 +198,29 @@ export const serviceCmd: Parameters<Crust["command"]>[1] = (cmd) =>
196
198
  cmd
197
199
  .meta({ description: "Manage systemd service" })
198
200
  .command("install", (sub) =>
199
- sub
200
- .meta({ description: "Install systemd service & start" })
201
- .run(async () => {
202
- await cmdInstall();
203
- })
201
+ sub.meta({ description: "Install systemd service & start" }).run(async () => {
202
+ await cmdInstall();
203
+ })
204
204
  )
205
205
  .command("status", (sub) =>
206
- sub
207
- .meta({ description: "Check service health" })
208
- .run(() => {
209
- cmdStatus();
210
- })
206
+ sub.meta({ description: "Check service health" }).run(() => {
207
+ cmdStatus();
208
+ })
211
209
  )
212
210
  .command("stop", (sub) =>
213
- sub
214
- .meta({ description: "Stop the service" })
215
- .run(() => {
216
- cmdStop();
217
- })
211
+ sub.meta({ description: "Stop the service" }).run(() => {
212
+ cmdStop();
213
+ })
218
214
  )
219
215
  .command("restart", (sub) =>
220
- sub
221
- .meta({ description: "Restart the service" })
222
- .run(() => {
223
- cmdRestart();
224
- })
216
+ sub.meta({ description: "Restart the service" }).run(() => {
217
+ cmdRestart();
218
+ })
225
219
  )
226
220
  .command("logs", (sub) =>
227
- sub
228
- .meta({ description: "Follow real-time logs" })
229
- .run(() => {
230
- cmdLogs();
231
- })
221
+ sub.meta({ description: "Follow real-time logs" }).run(() => {
222
+ cmdLogs();
223
+ })
232
224
  )
233
225
  .command("uninstall", (sub) =>
234
226
  sub
@@ -1,7 +1,6 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
- import { mkdir, unlink } from "fs/promises";
4
- import { existsSync } from "fs";
3
+ import { mkdir } from "fs/promises";
5
4
  import type { ServerMonConfig, ServerEntry } from "../types";
6
5
 
7
6
  const CONFIG_DIR = join(homedir(), ".irsyadulibad", "servermon");
@@ -61,9 +60,7 @@ export async function loadConfig(name?: string): Promise<ServerEntry | null> {
61
60
  };
62
61
  }
63
62
 
64
- export async function saveConfig(
65
- entry: ServerEntry & { name?: string },
66
- ): Promise<void> {
63
+ export async function saveConfig(entry: ServerEntry & { name?: string }): Promise<void> {
67
64
  const cfg = (await readConfig()) ?? { servers: {} };
68
65
  const key = entry.name ?? "default";
69
66
  cfg.servers[key] = {
@@ -1,6 +1,6 @@
1
1
  import { sendReport } from "../reporter";
2
2
  import { saveConfig, loadConfig, listServers } from "../config";
3
- import type { ServerEntry, NamedConfig } from "../types";
3
+ import type { NamedConfig } from "../types";
4
4
 
5
5
  /* ------------------------------------------------------------------ */
6
6
  /* Environment variables (set by cli/) */