@glassmkr/crucible 0.1.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/LICENSE +21 -0
- package/README.md +92 -0
- package/config/collector.example.yaml +43 -0
- package/dist/alerts/evaluator.d.ts +3 -0
- package/dist/alerts/evaluator.js +14 -0
- package/dist/alerts/evaluator.js.map +1 -0
- package/dist/alerts/rules.d.ts +7 -0
- package/dist/alerts/rules.js +203 -0
- package/dist/alerts/rules.js.map +1 -0
- package/dist/alerts/state.d.ts +6 -0
- package/dist/alerts/state.js +77 -0
- package/dist/alerts/state.js.map +1 -0
- package/dist/collect/cpu.d.ts +2 -0
- package/dist/collect/cpu.js +35 -0
- package/dist/collect/cpu.js.map +1 -0
- package/dist/collect/disks.d.ts +2 -0
- package/dist/collect/disks.js +33 -0
- package/dist/collect/disks.js.map +1 -0
- package/dist/collect/ipmi.d.ts +2 -0
- package/dist/collect/ipmi.js +55 -0
- package/dist/collect/ipmi.js.map +1 -0
- package/dist/collect/memory.d.ts +2 -0
- package/dist/collect/memory.js +27 -0
- package/dist/collect/memory.js.map +1 -0
- package/dist/collect/network.d.ts +2 -0
- package/dist/collect/network.js +54 -0
- package/dist/collect/network.js.map +1 -0
- package/dist/collect/os-alerts.d.ts +2 -0
- package/dist/collect/os-alerts.js +41 -0
- package/dist/collect/os-alerts.js.map +1 -0
- package/dist/collect/raid.d.ts +2 -0
- package/dist/collect/raid.js +34 -0
- package/dist/collect/raid.js.map +1 -0
- package/dist/collect/smart.d.ts +2 -0
- package/dist/collect/smart.js +56 -0
- package/dist/collect/smart.js.map +1 -0
- package/dist/collect/system.d.ts +2 -0
- package/dist/collect/system.js +19 -0
- package/dist/collect/system.js.map +1 -0
- package/dist/config.d.ts +208 -0
- package/dist/config.js +58 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +96 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/exec.d.ts +1 -0
- package/dist/lib/exec.js +19 -0
- package/dist/lib/exec.js.map +1 -0
- package/dist/lib/parse.d.ts +4 -0
- package/dist/lib/parse.js +29 -0
- package/dist/lib/parse.js.map +1 -0
- package/dist/lib/types.d.ts +103 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/notify/email.d.ts +4 -0
- package/dist/notify/email.js +55 -0
- package/dist/notify/email.js.map +1 -0
- package/dist/notify/slack.d.ts +2 -0
- package/dist/notify/slack.js +38 -0
- package/dist/notify/slack.js.map +1 -0
- package/dist/notify/telegram.d.ts +2 -0
- package/dist/notify/telegram.js +38 -0
- package/dist/notify/telegram.js.map +1 -0
- package/dist/push/forge.d.ts +2 -0
- package/dist/push/forge.js +26 -0
- package/dist/push/forge.js.map +1 -0
- package/package.json +29 -0
- package/src/alerts/evaluator.ts +15 -0
- package/src/alerts/rules.ts +184 -0
- package/src/alerts/state.ts +92 -0
- package/src/collect/cpu.ts +44 -0
- package/src/collect/disks.ts +36 -0
- package/src/collect/ipmi.ts +60 -0
- package/src/collect/memory.ts +30 -0
- package/src/collect/network.ts +61 -0
- package/src/collect/os-alerts.ts +43 -0
- package/src/collect/raid.ts +40 -0
- package/src/collect/smart.ts +60 -0
- package/src/collect/system.ts +21 -0
- package/src/config.ts +60 -0
- package/src/index.ts +112 -0
- package/src/lib/exec.ts +16 -0
- package/src/lib/parse.ts +29 -0
- package/src/lib/types.ts +110 -0
- package/src/notify/email.ts +68 -0
- package/src/notify/slack.ts +46 -0
- package/src/notify/telegram.ts +45 -0
- package/src/push/forge.ts +25 -0
- package/tsconfig.json +15 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const ConfigSchema = z.object({
|
|
6
|
+
server_name: z.string().default("unnamed-server"),
|
|
7
|
+
collection: z.object({
|
|
8
|
+
interval_seconds: z.number().min(60).max(3600).default(300),
|
|
9
|
+
ipmi: z.boolean().default(true),
|
|
10
|
+
smart: z.boolean().default(true),
|
|
11
|
+
}).default({}),
|
|
12
|
+
forge: z.object({
|
|
13
|
+
enabled: z.boolean().default(false),
|
|
14
|
+
url: z.string().default("https://forge.glassmkr.com"),
|
|
15
|
+
api_key: z.string().default(""),
|
|
16
|
+
}).default({}),
|
|
17
|
+
thresholds: z.object({
|
|
18
|
+
ram_percent: z.number().default(90),
|
|
19
|
+
swap_alert: z.boolean().default(true),
|
|
20
|
+
disk_percent: z.number().default(85),
|
|
21
|
+
iowait_percent: z.number().default(20),
|
|
22
|
+
nvme_wear_percent: z.number().default(85),
|
|
23
|
+
disk_latency_nvme_ms: z.number().default(50),
|
|
24
|
+
disk_latency_hdd_ms: z.number().default(200),
|
|
25
|
+
cpu_temp_warning_c: z.number().default(80),
|
|
26
|
+
cpu_temp_critical_c: z.number().default(90),
|
|
27
|
+
interface_utilization_percent: z.number().default(90),
|
|
28
|
+
}).default({}),
|
|
29
|
+
channels: z.object({
|
|
30
|
+
telegram: z.object({
|
|
31
|
+
enabled: z.boolean().default(false),
|
|
32
|
+
bot_token: z.string().default(""),
|
|
33
|
+
chat_id: z.string().default(""),
|
|
34
|
+
}).default({}),
|
|
35
|
+
email: z.object({
|
|
36
|
+
enabled: z.boolean().default(false),
|
|
37
|
+
to: z.string().default(""),
|
|
38
|
+
}).default({}),
|
|
39
|
+
slack: z.object({
|
|
40
|
+
enabled: z.boolean().default(false),
|
|
41
|
+
webhook_url: z.string().default(""),
|
|
42
|
+
}).default({}),
|
|
43
|
+
}).default({}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
47
|
+
|
|
48
|
+
export function loadConfig(path: string): Config {
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(path, "utf-8");
|
|
51
|
+
const parsed = parse(raw);
|
|
52
|
+
return ConfigSchema.parse(parsed);
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
if (err.code === "ENOENT") {
|
|
55
|
+
console.log(`[config] No config file at ${path}, using defaults`);
|
|
56
|
+
return ConfigSchema.parse({});
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { collectSystem } from "./collect/system.js";
|
|
5
|
+
import { collectCpu } from "./collect/cpu.js";
|
|
6
|
+
import { collectMemory } from "./collect/memory.js";
|
|
7
|
+
import { collectDisks } from "./collect/disks.js";
|
|
8
|
+
import { collectSmart } from "./collect/smart.js";
|
|
9
|
+
import { collectNetwork } from "./collect/network.js";
|
|
10
|
+
import { collectRaid } from "./collect/raid.js";
|
|
11
|
+
import { collectIpmi } from "./collect/ipmi.js";
|
|
12
|
+
import { collectOsAlerts } from "./collect/os-alerts.js";
|
|
13
|
+
import { evaluateAlerts } from "./alerts/evaluator.js";
|
|
14
|
+
import { updateAlertState } from "./alerts/state.js";
|
|
15
|
+
import { sendTelegram } from "./notify/telegram.js";
|
|
16
|
+
import { sendSlack } from "./notify/slack.js";
|
|
17
|
+
import { sendEmail } from "./notify/email.js";
|
|
18
|
+
import { pushToForge } from "./push/forge.js";
|
|
19
|
+
import type { Snapshot, IpmiInfo } from "./lib/types.js";
|
|
20
|
+
|
|
21
|
+
const configPath = process.argv[2] || "/etc/glassmkr/collector.yaml";
|
|
22
|
+
const config = loadConfig(configPath);
|
|
23
|
+
|
|
24
|
+
console.log(`[collector] Starting. Server: ${config.server_name}. Interval: ${config.collection.interval_seconds}s`);
|
|
25
|
+
console.log(`[collector] IPMI: ${config.collection.ipmi ? "enabled" : "disabled"}, SMART: ${config.collection.smart ? "enabled" : "disabled"}`);
|
|
26
|
+
console.log(`[collector] Forge: ${config.forge.enabled ? config.forge.url : "disabled"}`);
|
|
27
|
+
|
|
28
|
+
const emptyIpmi: IpmiInfo = { available: false, sensors: [], ecc_errors: { correctable: 0, uncorrectable: 0 }, sel_entries_count: 0 };
|
|
29
|
+
|
|
30
|
+
async function collect() {
|
|
31
|
+
const startTime = Date.now();
|
|
32
|
+
console.log(`[collector] Collecting...`);
|
|
33
|
+
|
|
34
|
+
const [system, cpu, memory, disks, smart, network, raid, ipmi, osAlerts] = await Promise.all([
|
|
35
|
+
collectSystem(),
|
|
36
|
+
collectCpu(),
|
|
37
|
+
collectMemory(),
|
|
38
|
+
collectDisks(),
|
|
39
|
+
config.collection.smart ? collectSmart() : Promise.resolve([]),
|
|
40
|
+
collectNetwork(),
|
|
41
|
+
collectRaid(),
|
|
42
|
+
config.collection.ipmi ? collectIpmi() : Promise.resolve(emptyIpmi),
|
|
43
|
+
collectOsAlerts(),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const snapshot: Snapshot = {
|
|
47
|
+
collector_version: "0.1.0",
|
|
48
|
+
timestamp: new Date().toISOString(),
|
|
49
|
+
system, cpu, memory, disks, smart, network, raid, ipmi, os_alerts: osAlerts,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Evaluate alerts
|
|
53
|
+
const alertResults = evaluateAlerts(snapshot, config.thresholds);
|
|
54
|
+
const { newAlerts, resolvedAlerts } = updateAlertState(alertResults);
|
|
55
|
+
|
|
56
|
+
const elapsed = Date.now() - startTime;
|
|
57
|
+
console.log(`[collector] Collected in ${elapsed}ms. Alerts: ${alertResults.length} active, ${newAlerts.length} new, ${resolvedAlerts.length} resolved`);
|
|
58
|
+
|
|
59
|
+
// Send notifications for new/resolved alerts
|
|
60
|
+
if (newAlerts.length > 0 || resolvedAlerts.length > 0) {
|
|
61
|
+
if (config.channels.telegram.enabled && config.channels.telegram.bot_token && config.channels.telegram.chat_id) {
|
|
62
|
+
await sendTelegram(config.channels.telegram.bot_token, config.channels.telegram.chat_id, newAlerts, resolvedAlerts, config.server_name);
|
|
63
|
+
}
|
|
64
|
+
if (config.channels.slack.enabled && config.channels.slack.webhook_url) {
|
|
65
|
+
await sendSlack(config.channels.slack.webhook_url, newAlerts, resolvedAlerts, config.server_name);
|
|
66
|
+
}
|
|
67
|
+
if (config.channels.email.enabled && config.channels.email.to) {
|
|
68
|
+
await sendEmail(config.channels.email, newAlerts, resolvedAlerts, config.server_name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Push to Forge (non-blocking)
|
|
73
|
+
if (config.forge.enabled && config.forge.api_key) {
|
|
74
|
+
pushToForge(config.forge.url, config.forge.api_key, snapshot);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Print summary on first run
|
|
78
|
+
if (firstRun) {
|
|
79
|
+
firstRun = false;
|
|
80
|
+
console.log("");
|
|
81
|
+
console.log("=== First collection complete ===");
|
|
82
|
+
console.log(`Server: ${system.hostname} (${system.os})`);
|
|
83
|
+
console.log(`CPU: ${cpu.user_percent.toFixed(1)}% (load: ${cpu.load_1m})`);
|
|
84
|
+
const ramPct = memory.total_mb > 0 ? ((memory.used_mb / memory.total_mb) * 100).toFixed(1) : "0";
|
|
85
|
+
console.log(`RAM: ${ramPct}% (${memory.used_mb} / ${memory.total_mb} MB)`);
|
|
86
|
+
if (disks.length > 0) console.log(`Disk: ${disks[0].percent_used}% (${disks[0].mount})`);
|
|
87
|
+
console.log(`SMART: ${smart.length > 0 ? `${smart.length} drive(s) checked` : "not available"}`);
|
|
88
|
+
console.log(`Network: ${network.map((n) => n.interface).join(", ") || "none detected"}`);
|
|
89
|
+
console.log(`IPMI: ${ipmi.available ? "available" : "not available"}`);
|
|
90
|
+
console.log(`Active alerts: ${alertResults.length}`);
|
|
91
|
+
console.log(`Forge: ${config.forge.enabled ? "enabled" : "disabled"}`);
|
|
92
|
+
console.log("");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let firstRun = true;
|
|
97
|
+
|
|
98
|
+
// Run immediately
|
|
99
|
+
collect();
|
|
100
|
+
|
|
101
|
+
// Then on interval
|
|
102
|
+
setInterval(collect, config.collection.interval_seconds * 1000);
|
|
103
|
+
|
|
104
|
+
process.on("SIGTERM", () => {
|
|
105
|
+
console.log("[collector] Received SIGTERM, shutting down");
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
process.on("SIGINT", () => {
|
|
110
|
+
console.log("[collector] Received SIGINT, shutting down");
|
|
111
|
+
process.exit(0);
|
|
112
|
+
});
|
package/src/lib/exec.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function run(cmd: string, args: string[], timeoutMs = 10000): Promise<string | null> {
|
|
7
|
+
try {
|
|
8
|
+
const { stdout } = await execFileAsync(cmd, args, { timeout: timeoutMs });
|
|
9
|
+
return stdout;
|
|
10
|
+
} catch (err: any) {
|
|
11
|
+
if (err.code === "ENOENT") return null; // command not installed
|
|
12
|
+
if (err.killed) return null; // timeout
|
|
13
|
+
if (err.stdout) return err.stdout; // non-zero exit but has output
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/lib/parse.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
|
|
3
|
+
export function readProcFile(path: string): string | null {
|
|
4
|
+
try {
|
|
5
|
+
return readFileSync(path, "utf-8");
|
|
6
|
+
} catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseKeyValue(raw: string): Record<string, string> {
|
|
12
|
+
const result: Record<string, string> = {};
|
|
13
|
+
for (const line of raw.split("\n")) {
|
|
14
|
+
const idx = line.indexOf(":");
|
|
15
|
+
if (idx === -1) continue;
|
|
16
|
+
result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseKb(val: string | undefined): number {
|
|
22
|
+
if (!val) return 0;
|
|
23
|
+
const num = parseInt(val.replace(/\s*kB$/i, ""), 10);
|
|
24
|
+
return isNaN(num) ? 0 : num;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sleep(ms: number): Promise<void> {
|
|
28
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export interface Snapshot {
|
|
2
|
+
collector_version: string;
|
|
3
|
+
timestamp: string;
|
|
4
|
+
system: SystemInfo;
|
|
5
|
+
cpu: CpuInfo;
|
|
6
|
+
memory: MemoryInfo;
|
|
7
|
+
disks: DiskInfo[];
|
|
8
|
+
smart: SmartInfo[];
|
|
9
|
+
network: NetworkInfo[];
|
|
10
|
+
raid: RaidInfo[];
|
|
11
|
+
ipmi: IpmiInfo;
|
|
12
|
+
os_alerts: OsAlerts;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SystemInfo {
|
|
16
|
+
hostname: string;
|
|
17
|
+
ip: string;
|
|
18
|
+
os: string;
|
|
19
|
+
kernel: string;
|
|
20
|
+
uptime_seconds: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CpuInfo {
|
|
24
|
+
user_percent: number;
|
|
25
|
+
system_percent: number;
|
|
26
|
+
iowait_percent: number;
|
|
27
|
+
idle_percent: number;
|
|
28
|
+
load_1m: number;
|
|
29
|
+
load_5m: number;
|
|
30
|
+
load_15m: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MemoryInfo {
|
|
34
|
+
total_mb: number;
|
|
35
|
+
used_mb: number;
|
|
36
|
+
available_mb: number;
|
|
37
|
+
swap_total_mb: number;
|
|
38
|
+
swap_used_mb: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DiskInfo {
|
|
42
|
+
device: string;
|
|
43
|
+
mount: string;
|
|
44
|
+
total_gb: number;
|
|
45
|
+
used_gb: number;
|
|
46
|
+
available_gb: number;
|
|
47
|
+
percent_used: number;
|
|
48
|
+
io_read_mb_s?: number;
|
|
49
|
+
io_write_mb_s?: number;
|
|
50
|
+
latency_p99_ms?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SmartInfo {
|
|
54
|
+
device: string;
|
|
55
|
+
model: string;
|
|
56
|
+
health: string;
|
|
57
|
+
temperature_c?: number;
|
|
58
|
+
percentage_used?: number;
|
|
59
|
+
reallocated_sectors?: number;
|
|
60
|
+
pending_sectors?: number;
|
|
61
|
+
power_on_hours?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface NetworkInfo {
|
|
65
|
+
interface: string;
|
|
66
|
+
speed_mbps: number;
|
|
67
|
+
rx_bytes_sec: number;
|
|
68
|
+
tx_bytes_sec: number;
|
|
69
|
+
rx_errors: number;
|
|
70
|
+
tx_errors: number;
|
|
71
|
+
rx_drops: number;
|
|
72
|
+
tx_drops: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RaidInfo {
|
|
76
|
+
device: string;
|
|
77
|
+
level: string;
|
|
78
|
+
status: string;
|
|
79
|
+
degraded: boolean;
|
|
80
|
+
disks: string[];
|
|
81
|
+
failed_disks: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface IpmiInfo {
|
|
85
|
+
available: boolean;
|
|
86
|
+
sensors: Array<{
|
|
87
|
+
name: string;
|
|
88
|
+
value: number | string;
|
|
89
|
+
unit: string;
|
|
90
|
+
status: string;
|
|
91
|
+
upper_critical?: number;
|
|
92
|
+
}>;
|
|
93
|
+
ecc_errors: { correctable: number; uncorrectable: number };
|
|
94
|
+
sel_entries_count: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface OsAlerts {
|
|
98
|
+
oom_kills_recent: number;
|
|
99
|
+
zombie_processes: number;
|
|
100
|
+
time_drift_ms: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface AlertResult {
|
|
104
|
+
type: string;
|
|
105
|
+
severity: "critical" | "warning";
|
|
106
|
+
title: string;
|
|
107
|
+
message: string;
|
|
108
|
+
evidence: Record<string, unknown>;
|
|
109
|
+
recommendation: string;
|
|
110
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import type { AlertResult } from "../lib/types.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export async function sendEmail(
|
|
8
|
+
config: { to: string },
|
|
9
|
+
newAlerts: AlertResult[],
|
|
10
|
+
resolvedAlerts: AlertResult[],
|
|
11
|
+
serverName: string
|
|
12
|
+
): Promise<boolean> {
|
|
13
|
+
if (!config.to) return false;
|
|
14
|
+
|
|
15
|
+
const subject = buildSubject(newAlerts, resolvedAlerts, serverName);
|
|
16
|
+
const body = buildBody(newAlerts, resolvedAlerts, serverName);
|
|
17
|
+
|
|
18
|
+
const email = [
|
|
19
|
+
`To: ${config.to}`,
|
|
20
|
+
`From: glassmkr-collector@${serverName}`,
|
|
21
|
+
`Subject: ${subject}`,
|
|
22
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
23
|
+
"",
|
|
24
|
+
body,
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const child = execFileAsync("/usr/sbin/sendmail", ["-t"], { timeout: 10000 });
|
|
29
|
+
child.child.stdin?.write(email);
|
|
30
|
+
child.child.stdin?.end();
|
|
31
|
+
await child;
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
console.error("[email] Failed to send. Is sendmail/postfix/msmtp installed?");
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildSubject(newAlerts: AlertResult[], resolvedAlerts: AlertResult[], serverName: string): string {
|
|
40
|
+
if (newAlerts.length > 0) {
|
|
41
|
+
const worst = newAlerts.find((a) => a.severity === "critical") ? "CRITICAL" : "WARNING";
|
|
42
|
+
return `[${worst}] ${serverName}: ${newAlerts.length} alert(s)`;
|
|
43
|
+
}
|
|
44
|
+
return `[RESOLVED] ${serverName}: ${resolvedAlerts.length} alert(s) cleared`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildBody(newAlerts: AlertResult[], resolvedAlerts: AlertResult[], serverName: string): string {
|
|
48
|
+
const lines: string[] = [];
|
|
49
|
+
lines.push(`Server: ${serverName}`);
|
|
50
|
+
lines.push(`Time: ${new Date().toISOString()}`);
|
|
51
|
+
lines.push("");
|
|
52
|
+
|
|
53
|
+
for (const a of newAlerts) {
|
|
54
|
+
lines.push(`[${a.severity.toUpperCase()}] ${a.title}`);
|
|
55
|
+
lines.push(a.message);
|
|
56
|
+
lines.push(`Action: ${a.recommendation}`);
|
|
57
|
+
lines.push("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const a of resolvedAlerts) {
|
|
61
|
+
lines.push(`[RESOLVED] ${a.title}`);
|
|
62
|
+
lines.push("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
lines.push("---");
|
|
66
|
+
lines.push("Glassmkr Collector v0.1.0");
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AlertResult } from "../lib/types.js";
|
|
2
|
+
|
|
3
|
+
export async function sendSlack(
|
|
4
|
+
webhookUrl: string,
|
|
5
|
+
newAlerts: AlertResult[],
|
|
6
|
+
resolvedAlerts: AlertResult[],
|
|
7
|
+
serverName: string
|
|
8
|
+
): Promise<boolean> {
|
|
9
|
+
const blocks: any[] = [];
|
|
10
|
+
|
|
11
|
+
if (newAlerts.length > 0) {
|
|
12
|
+
const criticals = newAlerts.filter((a) => a.severity === "critical");
|
|
13
|
+
const warnings = newAlerts.filter((a) => a.severity === "warning");
|
|
14
|
+
|
|
15
|
+
if (criticals.length > 0) {
|
|
16
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: `\u{1F534} *${criticals.length} CRITICAL* on *${serverName}*` } });
|
|
17
|
+
for (const a of criticals) blocks.push({ type: "section", text: { type: "mrkdwn", text: `*${a.title}*\n${a.recommendation}` } });
|
|
18
|
+
}
|
|
19
|
+
if (warnings.length > 0) {
|
|
20
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: `\u{1F7E1} *${warnings.length} WARNING* on *${serverName}*` } });
|
|
21
|
+
for (const a of warnings) blocks.push({ type: "section", text: { type: "mrkdwn", text: `*${a.title}*\n${a.recommendation}` } });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (resolvedAlerts.length > 0) {
|
|
26
|
+
blocks.push({ type: "section", text: { type: "mrkdwn", text: `\u2705 *${resolvedAlerts.length} resolved* on *${serverName}*` } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (blocks.length === 0) return true;
|
|
30
|
+
|
|
31
|
+
blocks.push({ type: "divider" });
|
|
32
|
+
blocks.push({ type: "context", elements: [{ type: "mrkdwn", text: "Glassmkr Collector v0.1.0" }] });
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(webhookUrl, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ blocks }),
|
|
39
|
+
signal: AbortSignal.timeout(10000),
|
|
40
|
+
});
|
|
41
|
+
return res.ok;
|
|
42
|
+
} catch {
|
|
43
|
+
console.error("[slack] Failed to send notification");
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AlertResult } from "../lib/types.js";
|
|
2
|
+
|
|
3
|
+
export async function sendTelegram(
|
|
4
|
+
botToken: string,
|
|
5
|
+
chatId: string,
|
|
6
|
+
newAlerts: AlertResult[],
|
|
7
|
+
resolvedAlerts: AlertResult[],
|
|
8
|
+
serverName: string
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
const parts: string[] = [];
|
|
11
|
+
|
|
12
|
+
if (newAlerts.length > 0) {
|
|
13
|
+
const criticals = newAlerts.filter((a) => a.severity === "critical");
|
|
14
|
+
const warnings = newAlerts.filter((a) => a.severity === "warning");
|
|
15
|
+
|
|
16
|
+
if (criticals.length > 0) {
|
|
17
|
+
parts.push(`\u{1F534} <b>${criticals.length} CRITICAL</b> on <b>${serverName}</b>:\n`);
|
|
18
|
+
for (const a of criticals) parts.push(` \u2022 <b>${a.title}</b>\n ${a.recommendation}\n`);
|
|
19
|
+
}
|
|
20
|
+
if (warnings.length > 0) {
|
|
21
|
+
parts.push(`\u{1F7E1} <b>${warnings.length} WARNING</b> on <b>${serverName}</b>:\n`);
|
|
22
|
+
for (const a of warnings) parts.push(` \u2022 <b>${a.title}</b>\n ${a.recommendation}\n`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (resolvedAlerts.length > 0) {
|
|
27
|
+
parts.push(`\u2705 <b>${resolvedAlerts.length} resolved</b> on <b>${serverName}</b>:\n`);
|
|
28
|
+
for (const a of resolvedAlerts) parts.push(` \u2022 ${a.title}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (parts.length === 0) return true;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
body: JSON.stringify({ chat_id: chatId, text: parts.join("\n"), parse_mode: "HTML", disable_web_page_preview: true }),
|
|
38
|
+
signal: AbortSignal.timeout(10000),
|
|
39
|
+
});
|
|
40
|
+
return res.ok;
|
|
41
|
+
} catch {
|
|
42
|
+
console.error("[telegram] Failed to send notification");
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Snapshot } from "../lib/types.js";
|
|
2
|
+
|
|
3
|
+
export async function pushToForge(url: string, apiKey: string, snapshot: Snapshot): Promise<boolean> {
|
|
4
|
+
try {
|
|
5
|
+
const response = await fetch(`${url}/api/v1/ingest`, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: {
|
|
8
|
+
Authorization: `Bearer ${apiKey}`,
|
|
9
|
+
"Content-Type": "application/json",
|
|
10
|
+
},
|
|
11
|
+
body: JSON.stringify(snapshot),
|
|
12
|
+
signal: AbortSignal.timeout(10000),
|
|
13
|
+
});
|
|
14
|
+
if (response.ok) {
|
|
15
|
+
const data = await response.json() as { new_alerts?: number; active_alerts?: number };
|
|
16
|
+
console.log(`[forge] Push successful. Active alerts: ${data.active_alerts ?? 0}`);
|
|
17
|
+
} else {
|
|
18
|
+
console.error(`[forge] Push failed: ${response.status} ${response.statusText}`);
|
|
19
|
+
}
|
|
20
|
+
return response.ok;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error("[forge] Push failed, will retry next cycle");
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"rootDir": "./src"
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"]
|
|
15
|
+
}
|