@irsyadulibad/servermon 1.1.0 → 1.2.0
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 +134 -34
- package/cli.ts +3 -212
- package/index.ts +5 -118
- package/package.json +8 -1
- package/src/cli/banner.ts +65 -0
- package/src/cli/index.ts +284 -0
- package/src/cli/service.ts +306 -0
- package/src/config/index.ts +84 -0
- package/src/daemon/index.ts +198 -0
- package/src/{monitor.ts → monitor/index.ts} +32 -80
- package/src/{reporter.ts → reporter/index.ts} +25 -20
- package/src/types/index.ts +71 -0
- package/src/config.ts +0 -46
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { sendReport } from "../reporter";
|
|
2
|
+
import { saveConfig, loadConfig, listServers } from "../config";
|
|
3
|
+
import type { NamedConfig } from "../types";
|
|
4
|
+
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
/* Environment variables (set by cli/) */
|
|
7
|
+
/* ------------------------------------------------------------------ */
|
|
8
|
+
|
|
9
|
+
const botToken = process.env["TELEGRAM_BOT_TOKEN"] ?? "";
|
|
10
|
+
let chatId = process.env["TELEGRAM_CHAT_ID"] ?? "";
|
|
11
|
+
const rawInterval = process.env["MONITOR_INTERVAL"] ?? "300";
|
|
12
|
+
const intervalSec = Math.max(30, parseInt(rawInterval) || 300);
|
|
13
|
+
const serverName = process.env["SERVER_NAME"] ?? "";
|
|
14
|
+
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
/* Auto-detect Telegram chat ID */
|
|
17
|
+
/* ------------------------------------------------------------------ */
|
|
18
|
+
|
|
19
|
+
async function autoDetectChatId(token: string): Promise<string | null> {
|
|
20
|
+
try {
|
|
21
|
+
const resp = await fetch(`https://api.telegram.org/bot${token}/getUpdates?limit=5`);
|
|
22
|
+
const data = (await resp.json()) as {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
result?: Array<{
|
|
25
|
+
message?: { chat?: { id: number; title?: string; first_name?: string } };
|
|
26
|
+
channel_post?: { chat?: { id: number; title?: string } };
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
if (!data.ok || !data.result?.length) return null;
|
|
30
|
+
|
|
31
|
+
const chatIds = new Set<string>();
|
|
32
|
+
for (const update of data.result.reverse()) {
|
|
33
|
+
const chat = update.message?.chat || update.channel_post?.chat;
|
|
34
|
+
if (chat?.id) chatIds.add(String(chat.id));
|
|
35
|
+
}
|
|
36
|
+
if (chatIds.size === 0) return null;
|
|
37
|
+
|
|
38
|
+
const id = [...chatIds][0]!;
|
|
39
|
+
const chatInfo = data.result.find(
|
|
40
|
+
(u) => String(u.message?.chat?.id || u.channel_post?.chat?.id) === id
|
|
41
|
+
);
|
|
42
|
+
const chatName =
|
|
43
|
+
chatInfo?.message?.chat?.title ||
|
|
44
|
+
chatInfo?.message?.chat?.first_name ||
|
|
45
|
+
chatInfo?.channel_post?.chat?.title ||
|
|
46
|
+
"Unknown";
|
|
47
|
+
console.log(`🔍 Auto-detected chat: ${chatName} (ID: ${id})`);
|
|
48
|
+
return id;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ------------------------------------------------------------------ */
|
|
55
|
+
/* Persist chat ID to config file */
|
|
56
|
+
/* ------------------------------------------------------------------ */
|
|
57
|
+
|
|
58
|
+
async function persistChatId(id: string, name?: string): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
const cfg = await loadConfig(name);
|
|
61
|
+
if (cfg) {
|
|
62
|
+
cfg.chatId = id;
|
|
63
|
+
await saveConfig(cfg);
|
|
64
|
+
console.log("💾 Chat ID saved to config");
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// ok
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ------------------------------------------------------------------ */
|
|
72
|
+
/* Single-server daemon */
|
|
73
|
+
/* ------------------------------------------------------------------ */
|
|
74
|
+
|
|
75
|
+
export async function start(): Promise<void> {
|
|
76
|
+
if (!botToken) {
|
|
77
|
+
console.error("❌ TELEGRAM_BOT_TOKEN not set. Run `servermon` first to configure.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!chatId) {
|
|
82
|
+
console.log("🔍 TELEGRAM_CHAT_ID not set — auto-detecting...");
|
|
83
|
+
let detected = await autoDetectChatId(botToken);
|
|
84
|
+
|
|
85
|
+
if (!detected) {
|
|
86
|
+
console.log("⏳ Waiting for you to DM the bot on Telegram...");
|
|
87
|
+
console.log(" (polling every 10s — no restart needed)\n");
|
|
88
|
+
|
|
89
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
90
|
+
while (!detected) {
|
|
91
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
92
|
+
detected = await autoDetectChatId(botToken);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
chatId = detected;
|
|
97
|
+
await persistChatId(chatId, serverName || undefined);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(`⏱ Interval: ${intervalSec}s (${(intervalSec / 60).toFixed(0)} menit)`);
|
|
101
|
+
console.log(`📡 Bot: ...${botToken.slice(-8)}`);
|
|
102
|
+
console.log(`💬 Chat: ${chatId}`);
|
|
103
|
+
if (serverName) console.log(`🏷 Server: ${serverName}`);
|
|
104
|
+
console.log();
|
|
105
|
+
|
|
106
|
+
async function tick() {
|
|
107
|
+
const start2 = Date.now();
|
|
108
|
+
const ok = await sendReport(botToken, chatId, serverName || undefined);
|
|
109
|
+
const elapsed = Date.now() - start2;
|
|
110
|
+
const ts = new Date().toLocaleString("id-ID", { timeZone: "Asia/Jakarta" });
|
|
111
|
+
console.log(`[${ts}] ${ok ? "✅" : "❌"} ${elapsed}ms`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await tick();
|
|
115
|
+
setInterval(tick, intervalSec * 1000);
|
|
116
|
+
|
|
117
|
+
process.on("SIGINT", () => {
|
|
118
|
+
console.log("\n👋 Shutting down...");
|
|
119
|
+
process.exit(0);
|
|
120
|
+
});
|
|
121
|
+
process.on("SIGTERM", () => {
|
|
122
|
+
console.log("\n👋 Shutting down...");
|
|
123
|
+
process.exit(0);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ------------------------------------------------------------------ */
|
|
128
|
+
/* Multi-server daemon (runs all configured servers) */
|
|
129
|
+
/* ------------------------------------------------------------------ */
|
|
130
|
+
|
|
131
|
+
export async function startAll(): Promise<void> {
|
|
132
|
+
const servers = await listServers();
|
|
133
|
+
if (servers.length === 0) {
|
|
134
|
+
console.error("❌ No configured servers found. Run `servermon setup` first.");
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log(`📋 Found ${servers.length} server(s) to monitor:\n`);
|
|
139
|
+
|
|
140
|
+
const configs: NamedConfig[] = [];
|
|
141
|
+
for (const s of servers) {
|
|
142
|
+
const cfg = await loadConfig(s === "default" ? undefined : s);
|
|
143
|
+
if (!cfg) {
|
|
144
|
+
console.log(` ⚠️ ${s}: config unreadable, skipping`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!cfg.chatId) {
|
|
148
|
+
if (configs.length === 0) {
|
|
149
|
+
console.log(`🔍 ${s}: Chat ID not set — auto-detecting...`);
|
|
150
|
+
const detected = await autoDetectChatId(cfg.token);
|
|
151
|
+
if (detected) {
|
|
152
|
+
cfg.chatId = detected;
|
|
153
|
+
await saveConfig({ ...cfg, chatId: detected });
|
|
154
|
+
console.log(`💾 ${s}: Chat ID ${detected} saved`);
|
|
155
|
+
} else {
|
|
156
|
+
console.log(` ❌ ${s}: no chat ID yet. DM your bot first then restart.`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
console.log(` ❌ ${s}: no chat ID. Run 'servermon start --name ${s}' first.`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
configs.push({ name: s, cfg });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (configs.length === 0) {
|
|
168
|
+
console.error("❌ No servers ready to monitor.");
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log();
|
|
173
|
+
console.log(`🔄 Monitoring ${configs.length} server(s) — reports every ${intervalSec}s`);
|
|
174
|
+
console.log();
|
|
175
|
+
|
|
176
|
+
async function tickAll() {
|
|
177
|
+
const ts = new Date().toLocaleString("id-ID", { timeZone: "Asia/Jakarta" });
|
|
178
|
+
for (const { name, cfg } of configs) {
|
|
179
|
+
if (!cfg.chatId) continue;
|
|
180
|
+
const start2 = Date.now();
|
|
181
|
+
const ok = await sendReport(cfg.token, cfg.chatId, name === "default" ? undefined : name);
|
|
182
|
+
const elapsed = Date.now() - start2;
|
|
183
|
+
console.log(`[${ts}] ${ok ? "✅" : "❌"} ${name} — ${elapsed}ms`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await tickAll();
|
|
188
|
+
setInterval(tickAll, intervalSec * 1000);
|
|
189
|
+
|
|
190
|
+
process.on("SIGINT", () => {
|
|
191
|
+
console.log("\n👋 Shutting down...");
|
|
192
|
+
process.exit(0);
|
|
193
|
+
});
|
|
194
|
+
process.on("SIGTERM", () => {
|
|
195
|
+
console.log("\n👋 Shutting down...");
|
|
196
|
+
process.exit(0);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -1,56 +1,41 @@
|
|
|
1
1
|
import * as os from "os";
|
|
2
|
+
import type { DiskInfo, ProcInfo, SystemMetrics } from "../types";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
loadAvg: { "1min": number; "5min": number; "15min": number };
|
|
7
|
-
usagePercent: number;
|
|
8
|
-
}
|
|
4
|
+
/* ------------------------------------------------------------------ */
|
|
5
|
+
/* Helpers */
|
|
6
|
+
/* ------------------------------------------------------------------ */
|
|
9
7
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
swapTotal: number;
|
|
16
|
-
swapUsed: number;
|
|
17
|
-
swapUsagePercent: number;
|
|
8
|
+
export function formatBytes(bytes: number): string {
|
|
9
|
+
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GiB`;
|
|
10
|
+
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MiB`;
|
|
11
|
+
if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(1)} KiB`;
|
|
12
|
+
return `${bytes} B`;
|
|
18
13
|
}
|
|
19
14
|
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
available: number;
|
|
25
|
-
usagePercent: number;
|
|
15
|
+
export function formatRate(bytesPerSec: number): string {
|
|
16
|
+
if (bytesPerSec >= 1_048_576) return `${(bytesPerSec / 1_048_576).toFixed(2)} MB/s`;
|
|
17
|
+
if (bytesPerSec >= 1_024) return `${(bytesPerSec / 1_024).toFixed(1)} KB/s`;
|
|
18
|
+
return `${bytesPerSec} B/s`;
|
|
26
19
|
}
|
|
27
20
|
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
export function formatUptime(seconds: number): string {
|
|
22
|
+
const d = Math.floor(seconds / 86400);
|
|
23
|
+
const h = Math.floor((seconds % 86400) / 3600);
|
|
24
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
25
|
+
const parts: string[] = [];
|
|
26
|
+
if (d > 0) parts.push(`${d}d`);
|
|
27
|
+
if (h > 0) parts.push(`${h}h`);
|
|
28
|
+
parts.push(`${m}m`);
|
|
29
|
+
return parts.join(" ");
|
|
33
30
|
}
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
name: string;
|
|
38
|
-
cpuPercent: number;
|
|
39
|
-
memPercent: number;
|
|
32
|
+
function cpuTicks(stat: string): number[] {
|
|
33
|
+
return stat.split(/\s+/).slice(1).map(Number);
|
|
40
34
|
}
|
|
41
35
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
arch: string;
|
|
46
|
-
uptime: number;
|
|
47
|
-
cpu: CPUMetrics;
|
|
48
|
-
memory: MemoryMetrics;
|
|
49
|
-
disks: DiskInfo[];
|
|
50
|
-
network: NetworkMetrics;
|
|
51
|
-
topProcs: ProcInfo[];
|
|
52
|
-
temperature: number | null; // celsius
|
|
53
|
-
}
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
/* Low-level collectors */
|
|
38
|
+
/* ------------------------------------------------------------------ */
|
|
54
39
|
|
|
55
40
|
async function exec(cmd: string): Promise<string> {
|
|
56
41
|
const proc = Bun.spawn(["bash", "-c", cmd], { stdout: "pipe" });
|
|
@@ -77,9 +62,7 @@ async function readNetDev(): Promise<{ rx: number; tx: number }> {
|
|
|
77
62
|
for (const line of data.split("\n")) {
|
|
78
63
|
if (!line.includes(":")) continue;
|
|
79
64
|
const ifname = line.split(":")[0]!.trim();
|
|
80
|
-
// Skip loopback
|
|
81
65
|
if (ifname === "lo") continue;
|
|
82
|
-
// Skip veth, docker
|
|
83
66
|
if (ifname.startsWith("veth") || ifname.startsWith("docker") || ifname.startsWith("br-"))
|
|
84
67
|
continue;
|
|
85
68
|
const parts = line.split(":")[1]!.trim().split(/\s+/);
|
|
@@ -95,14 +78,6 @@ async function readTemperature(): Promise<number | null> {
|
|
|
95
78
|
`for z in /sys/class/thermal/thermal_zone*/temp; do [ -r "$z" ] && echo "$z=$(cat "$z")"; done 2>/dev/null`
|
|
96
79
|
);
|
|
97
80
|
if (!zones) return null;
|
|
98
|
-
let best = Number.MAX_VALUE;
|
|
99
|
-
for (const line of zones.split("\n")) {
|
|
100
|
-
const val = parseInt(line.split("=")[1]!);
|
|
101
|
-
// thermal_zone0 often the CPU package; pick the highest non-zero temp
|
|
102
|
-
if (val > 0 && val < best) best = val;
|
|
103
|
-
// Actually we want the HIGHEST, not lowest
|
|
104
|
-
}
|
|
105
|
-
// Reread — pick highest
|
|
106
81
|
let highest = -Infinity;
|
|
107
82
|
for (const line of zones.split("\n")) {
|
|
108
83
|
const val = parseInt(line.split("=")[1]!);
|
|
@@ -114,6 +89,10 @@ async function readTemperature(): Promise<number | null> {
|
|
|
114
89
|
}
|
|
115
90
|
}
|
|
116
91
|
|
|
92
|
+
/* ------------------------------------------------------------------ */
|
|
93
|
+
/* Public API */
|
|
94
|
+
/* ------------------------------------------------------------------ */
|
|
95
|
+
|
|
117
96
|
export async function collectMetrics(): Promise<SystemMetrics> {
|
|
118
97
|
// --- disk ---
|
|
119
98
|
const dfOutput = await exec("df -P -B1 / /home 2>/dev/null");
|
|
@@ -141,9 +120,6 @@ export async function collectMetrics(): Promise<SystemMetrics> {
|
|
|
141
120
|
await new Promise((r) => setTimeout(r, 500));
|
|
142
121
|
const stat2 = await exec("cat /proc/stat | grep '^cpu '");
|
|
143
122
|
|
|
144
|
-
function cpuTicks(stat: string): number[] {
|
|
145
|
-
return stat.split(/\s+/).slice(1).map(Number);
|
|
146
|
-
}
|
|
147
123
|
const t1 = cpuTicks(stat1);
|
|
148
124
|
const t2 = cpuTicks(stat2);
|
|
149
125
|
let usagePercent = 0;
|
|
@@ -161,7 +137,7 @@ export async function collectMetrics(): Promise<SystemMetrics> {
|
|
|
161
137
|
const net1 = await readNetDev();
|
|
162
138
|
await new Promise((r) => setTimeout(r, 1000));
|
|
163
139
|
const net2 = await readNetDev();
|
|
164
|
-
const rxRate = Math.max(0, net2.rx - net1.rx);
|
|
140
|
+
const rxRate = Math.max(0, net2.rx - net1.rx);
|
|
165
141
|
const txRate = Math.max(0, net2.tx - net1.tx);
|
|
166
142
|
|
|
167
143
|
// --- top processes ---
|
|
@@ -210,27 +186,3 @@ export async function collectMetrics(): Promise<SystemMetrics> {
|
|
|
210
186
|
temperature,
|
|
211
187
|
};
|
|
212
188
|
}
|
|
213
|
-
|
|
214
|
-
export function formatBytes(bytes: number): string {
|
|
215
|
-
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GiB`;
|
|
216
|
-
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MiB`;
|
|
217
|
-
if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(1)} KiB`;
|
|
218
|
-
return `${bytes} B`;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export function formatRate(bytesPerSec: number): string {
|
|
222
|
-
if (bytesPerSec >= 1_048_576) return `${(bytesPerSec / 1_048_576).toFixed(2)} MB/s`;
|
|
223
|
-
if (bytesPerSec >= 1_024) return `${(bytesPerSec / 1_024).toFixed(1)} KB/s`;
|
|
224
|
-
return `${bytesPerSec} B/s`;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export function formatUptime(seconds: number): string {
|
|
228
|
-
const d = Math.floor(seconds / 86400);
|
|
229
|
-
const h = Math.floor((seconds % 86400) / 3600);
|
|
230
|
-
const m = Math.floor((seconds % 3600) / 60);
|
|
231
|
-
const parts: string[] = [];
|
|
232
|
-
if (d > 0) parts.push(`${d}d`);
|
|
233
|
-
if (h > 0) parts.push(`${h}h`);
|
|
234
|
-
parts.push(`${m}m`);
|
|
235
|
-
return parts.join(" ");
|
|
236
|
-
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SystemMetrics } from "../types";
|
|
2
|
+
import { collectMetrics, formatBytes, formatRate, formatUptime } from "../monitor";
|
|
3
|
+
|
|
4
|
+
/* ------------------------------------------------------------------ */
|
|
5
|
+
/* Helpers */
|
|
6
|
+
/* ------------------------------------------------------------------ */
|
|
2
7
|
|
|
3
|
-
// HTML escape
|
|
4
8
|
function esc(s: string): string {
|
|
5
9
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
6
10
|
}
|
|
7
11
|
|
|
8
|
-
// Bar chart with inline colored blocks
|
|
9
12
|
function bar(percent: number, w = 10): string {
|
|
10
13
|
const filled = Math.min(w, Math.max(0, Math.round((percent / 100) * w)));
|
|
11
14
|
const empty = w - filled;
|
|
@@ -17,7 +20,6 @@ function pad(n: number, dp = 1): string {
|
|
|
17
20
|
return n.toFixed(dp).padStart(5);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
// Overall health
|
|
21
23
|
function healthTag(m: SystemMetrics): string {
|
|
22
24
|
const d = m.disks.length ? Math.max(...m.disks.map((x) => x.usagePercent)) : 0;
|
|
23
25
|
if (m.cpu.usagePercent > 85 || m.memory.usagePercent > 95 || d > 95) return "🚨 CRITICAL";
|
|
@@ -25,7 +27,11 @@ function healthTag(m: SystemMetrics): string {
|
|
|
25
27
|
return "✅ HEALTHY";
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
/* ------------------------------------------------------------------ */
|
|
31
|
+
/* Formatter */
|
|
32
|
+
/* ------------------------------------------------------------------ */
|
|
33
|
+
|
|
34
|
+
export function formatReportHTML(m: SystemMetrics, serverName?: string): string {
|
|
29
35
|
const now = new Date().toLocaleString("id-ID", {
|
|
30
36
|
timeZone: "Asia/Jakarta",
|
|
31
37
|
day: "numeric",
|
|
@@ -36,21 +42,19 @@ export function formatReportHTML(m: SystemMetrics): string {
|
|
|
36
42
|
});
|
|
37
43
|
|
|
38
44
|
const tempStr = m.temperature !== null ? ` 🌡 ${m.temperature.toFixed(0)}°C` : "";
|
|
45
|
+
const nameTag = serverName ? ` [${esc(serverName)}]` : "";
|
|
39
46
|
|
|
40
|
-
// ── Header ──
|
|
41
47
|
const header = [
|
|
42
|
-
`<b>🖥 ${esc(m.hostname)}</b
|
|
48
|
+
`<b>🖥 ${esc(m.hostname)}</b>${nameTag} — ${healthTag(m)}`,
|
|
43
49
|
`📅 ${esc(now)} │ ⏱ ${esc(formatUptime(m.uptime))}${tempStr}`,
|
|
44
50
|
`🐧 ${esc(m.platform)} ${esc(m.arch)} │ ${esc(m.cpu.model)} (${m.cpu.cores}c)`,
|
|
45
51
|
];
|
|
46
52
|
|
|
47
|
-
// ── CPU card ──
|
|
48
53
|
const cpu = [
|
|
49
54
|
`<b>💻 CPU</b> <code>${pad(m.cpu.usagePercent)}%</code> ${bar(m.cpu.usagePercent)}`,
|
|
50
55
|
` Load: <code>${m.cpu.loadAvg["1min"].toFixed(2)}</code> / <code>${m.cpu.loadAvg["5min"].toFixed(2)}</code> / <code>${m.cpu.loadAvg["15min"].toFixed(2)}</code>`,
|
|
51
56
|
];
|
|
52
57
|
|
|
53
|
-
// ── Memory card ──
|
|
54
58
|
const mem = [
|
|
55
59
|
`<b>🧠 RAM</b> <code>${pad(m.memory.usagePercent)}%</code> ${bar(m.memory.usagePercent)}`,
|
|
56
60
|
` <code>${esc(formatBytes(m.memory.used))}</code> / <code>${esc(formatBytes(m.memory.total))}</code>`,
|
|
@@ -59,7 +63,6 @@ export function formatReportHTML(m: SystemMetrics): string {
|
|
|
59
63
|
: "",
|
|
60
64
|
].filter(Boolean);
|
|
61
65
|
|
|
62
|
-
// ── Disk card ──
|
|
63
66
|
const disks = [`<b>💾 DISK</b>`];
|
|
64
67
|
for (const d of m.disks) {
|
|
65
68
|
disks.push(
|
|
@@ -70,13 +73,11 @@ export function formatReportHTML(m: SystemMetrics): string {
|
|
|
70
73
|
);
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
// ── Network card ──
|
|
74
76
|
const net = [
|
|
75
77
|
`<b>🌐 NET</b>`,
|
|
76
78
|
` ↓ <code>${esc(formatRate(m.network.rxRate))}</code> ↑ <code>${esc(formatRate(m.network.txRate))}</code>`,
|
|
77
79
|
];
|
|
78
80
|
|
|
79
|
-
// ── Top processes ──
|
|
80
81
|
const procLines: string[] = [];
|
|
81
82
|
if (m.topProcs.length > 0) {
|
|
82
83
|
procLines.push(`<b>📊 TOP PROCESSES</b>`);
|
|
@@ -87,7 +88,6 @@ export function formatReportHTML(m: SystemMetrics): string {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
// ── Alerts ──
|
|
91
91
|
const alerts: string[] = [];
|
|
92
92
|
if (m.cpu.usagePercent > 85) alerts.push(`🔴 CPU tinggi: ${m.cpu.usagePercent.toFixed(1)}%`);
|
|
93
93
|
if (m.memory.usagePercent > 90)
|
|
@@ -98,10 +98,7 @@ export function formatReportHTML(m: SystemMetrics): string {
|
|
|
98
98
|
if (d.usagePercent > 90)
|
|
99
99
|
alerts.push(`🔴 Disk <code>${esc(d.mount)}</code>: ${d.usagePercent}%`);
|
|
100
100
|
}
|
|
101
|
-
|
|
102
|
-
if (alerts.length > 0) {
|
|
103
|
-
alerts.unshift(`<b>⚠️ ALERTS</b>`);
|
|
104
|
-
}
|
|
101
|
+
if (alerts.length > 0) alerts.unshift(`<b>⚠️ ALERTS</b>`);
|
|
105
102
|
|
|
106
103
|
return [
|
|
107
104
|
...header,
|
|
@@ -122,6 +119,10 @@ export function formatReportHTML(m: SystemMetrics): string {
|
|
|
122
119
|
.join("\n");
|
|
123
120
|
}
|
|
124
121
|
|
|
122
|
+
/* ------------------------------------------------------------------ */
|
|
123
|
+
/* Telegram sender */
|
|
124
|
+
/* ------------------------------------------------------------------ */
|
|
125
|
+
|
|
125
126
|
async function sendMessage(botToken: string, chatId: string, text: string): Promise<Response> {
|
|
126
127
|
return fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
127
128
|
method: "POST",
|
|
@@ -135,14 +136,18 @@ async function sendMessage(botToken: string, chatId: string, text: string): Prom
|
|
|
135
136
|
});
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
export async function sendReport(
|
|
139
|
+
export async function sendReport(
|
|
140
|
+
botToken: string,
|
|
141
|
+
chatId: string,
|
|
142
|
+
serverName?: string
|
|
143
|
+
): Promise<boolean> {
|
|
139
144
|
if (!botToken || !chatId) {
|
|
140
145
|
console.error("❌ TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are required.");
|
|
141
146
|
return false;
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
const m = await
|
|
145
|
-
const report = formatReportHTML(m);
|
|
149
|
+
const m = await collectMetrics();
|
|
150
|
+
const report = formatReportHTML(m, serverName);
|
|
146
151
|
|
|
147
152
|
// Telegram 4096 char limit — split on double newlines
|
|
148
153
|
if (report.length > 4000) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** CPU metrics */
|
|
2
|
+
export interface CPUMetrics {
|
|
3
|
+
model: string;
|
|
4
|
+
cores: number;
|
|
5
|
+
loadAvg: { "1min": number; "5min": number; "15min": number };
|
|
6
|
+
usagePercent: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Memory & swap metrics */
|
|
10
|
+
export interface MemoryMetrics {
|
|
11
|
+
total: number;
|
|
12
|
+
used: number;
|
|
13
|
+
free: number;
|
|
14
|
+
usagePercent: number;
|
|
15
|
+
swapTotal: number;
|
|
16
|
+
swapUsed: number;
|
|
17
|
+
swapUsagePercent: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Per-mount disk info */
|
|
21
|
+
export interface DiskInfo {
|
|
22
|
+
mount: string;
|
|
23
|
+
total: number;
|
|
24
|
+
used: number;
|
|
25
|
+
available: number;
|
|
26
|
+
usagePercent: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Network throughput */
|
|
30
|
+
export interface NetworkMetrics {
|
|
31
|
+
rxRate: number; // bytes/sec
|
|
32
|
+
txRate: number;
|
|
33
|
+
rxTotal: number;
|
|
34
|
+
txTotal: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Single process info */
|
|
38
|
+
export interface ProcInfo {
|
|
39
|
+
pid: number;
|
|
40
|
+
name: string;
|
|
41
|
+
cpuPercent: number;
|
|
42
|
+
memPercent: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Full system snapshot returned by collectMetrics() */
|
|
46
|
+
export interface SystemMetrics {
|
|
47
|
+
hostname: string;
|
|
48
|
+
platform: string;
|
|
49
|
+
arch: string;
|
|
50
|
+
uptime: number;
|
|
51
|
+
cpu: CPUMetrics;
|
|
52
|
+
memory: MemoryMetrics;
|
|
53
|
+
disks: DiskInfo[];
|
|
54
|
+
network: NetworkMetrics;
|
|
55
|
+
topProcs: ProcInfo[];
|
|
56
|
+
temperature: number | null; // celsius
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** ServerMon config persisted to disk */
|
|
60
|
+
export interface ServerMonConfig {
|
|
61
|
+
token: string;
|
|
62
|
+
interval: number;
|
|
63
|
+
chatId?: string;
|
|
64
|
+
name?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A named config loaded at runtime — internal for multi-server loop */
|
|
68
|
+
export interface NamedConfig {
|
|
69
|
+
name: string;
|
|
70
|
+
cfg: ServerMonConfig;
|
|
71
|
+
}
|
package/src/config.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { homedir } from "os";
|
|
2
|
-
import { join } from "path";
|
|
3
|
-
import { mkdir } from "fs/promises";
|
|
4
|
-
|
|
5
|
-
const CONFIG_DIR = join(homedir(), ".irsyadulibad", "servermon");
|
|
6
|
-
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
|
-
|
|
8
|
-
export interface ServerMonConfig {
|
|
9
|
-
token: string;
|
|
10
|
-
interval: number;
|
|
11
|
-
chatId?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function ensureConfigDir(): Promise<void> {
|
|
15
|
-
await mkdir(CONFIG_DIR, { recursive: true });
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function loadConfig(): Promise<ServerMonConfig | null> {
|
|
19
|
-
try {
|
|
20
|
-
const file = Bun.file(CONFIG_FILE);
|
|
21
|
-
if (!(await file.exists())) return null;
|
|
22
|
-
const data = await file.json();
|
|
23
|
-
// Minimal validation
|
|
24
|
-
if (!data?.token) return null;
|
|
25
|
-
return {
|
|
26
|
-
token: String(data.token),
|
|
27
|
-
interval: Math.max(30, parseInt(String(data.interval)) || 300),
|
|
28
|
-
chatId: data.chatId ? String(data.chatId) : undefined,
|
|
29
|
-
};
|
|
30
|
-
} catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function saveConfig(config: ServerMonConfig): Promise<void> {
|
|
36
|
-
await ensureConfigDir();
|
|
37
|
-
await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function configPath(): string {
|
|
41
|
-
return CONFIG_FILE;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function configDir(): string {
|
|
45
|
-
return CONFIG_DIR;
|
|
46
|
-
}
|