@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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/config/collector.example.yaml +43 -0
  4. package/dist/alerts/evaluator.d.ts +3 -0
  5. package/dist/alerts/evaluator.js +14 -0
  6. package/dist/alerts/evaluator.js.map +1 -0
  7. package/dist/alerts/rules.d.ts +7 -0
  8. package/dist/alerts/rules.js +203 -0
  9. package/dist/alerts/rules.js.map +1 -0
  10. package/dist/alerts/state.d.ts +6 -0
  11. package/dist/alerts/state.js +77 -0
  12. package/dist/alerts/state.js.map +1 -0
  13. package/dist/collect/cpu.d.ts +2 -0
  14. package/dist/collect/cpu.js +35 -0
  15. package/dist/collect/cpu.js.map +1 -0
  16. package/dist/collect/disks.d.ts +2 -0
  17. package/dist/collect/disks.js +33 -0
  18. package/dist/collect/disks.js.map +1 -0
  19. package/dist/collect/ipmi.d.ts +2 -0
  20. package/dist/collect/ipmi.js +55 -0
  21. package/dist/collect/ipmi.js.map +1 -0
  22. package/dist/collect/memory.d.ts +2 -0
  23. package/dist/collect/memory.js +27 -0
  24. package/dist/collect/memory.js.map +1 -0
  25. package/dist/collect/network.d.ts +2 -0
  26. package/dist/collect/network.js +54 -0
  27. package/dist/collect/network.js.map +1 -0
  28. package/dist/collect/os-alerts.d.ts +2 -0
  29. package/dist/collect/os-alerts.js +41 -0
  30. package/dist/collect/os-alerts.js.map +1 -0
  31. package/dist/collect/raid.d.ts +2 -0
  32. package/dist/collect/raid.js +34 -0
  33. package/dist/collect/raid.js.map +1 -0
  34. package/dist/collect/smart.d.ts +2 -0
  35. package/dist/collect/smart.js +56 -0
  36. package/dist/collect/smart.js.map +1 -0
  37. package/dist/collect/system.d.ts +2 -0
  38. package/dist/collect/system.js +19 -0
  39. package/dist/collect/system.js.map +1 -0
  40. package/dist/config.d.ts +208 -0
  41. package/dist/config.js +58 -0
  42. package/dist/config.js.map +1 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.js +96 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/lib/exec.d.ts +1 -0
  47. package/dist/lib/exec.js +19 -0
  48. package/dist/lib/exec.js.map +1 -0
  49. package/dist/lib/parse.d.ts +4 -0
  50. package/dist/lib/parse.js +29 -0
  51. package/dist/lib/parse.js.map +1 -0
  52. package/dist/lib/types.d.ts +103 -0
  53. package/dist/lib/types.js +2 -0
  54. package/dist/lib/types.js.map +1 -0
  55. package/dist/notify/email.d.ts +4 -0
  56. package/dist/notify/email.js +55 -0
  57. package/dist/notify/email.js.map +1 -0
  58. package/dist/notify/slack.d.ts +2 -0
  59. package/dist/notify/slack.js +38 -0
  60. package/dist/notify/slack.js.map +1 -0
  61. package/dist/notify/telegram.d.ts +2 -0
  62. package/dist/notify/telegram.js +38 -0
  63. package/dist/notify/telegram.js.map +1 -0
  64. package/dist/push/forge.d.ts +2 -0
  65. package/dist/push/forge.js +26 -0
  66. package/dist/push/forge.js.map +1 -0
  67. package/package.json +29 -0
  68. package/src/alerts/evaluator.ts +15 -0
  69. package/src/alerts/rules.ts +184 -0
  70. package/src/alerts/state.ts +92 -0
  71. package/src/collect/cpu.ts +44 -0
  72. package/src/collect/disks.ts +36 -0
  73. package/src/collect/ipmi.ts +60 -0
  74. package/src/collect/memory.ts +30 -0
  75. package/src/collect/network.ts +61 -0
  76. package/src/collect/os-alerts.ts +43 -0
  77. package/src/collect/raid.ts +40 -0
  78. package/src/collect/smart.ts +60 -0
  79. package/src/collect/system.ts +21 -0
  80. package/src/config.ts +60 -0
  81. package/src/index.ts +112 -0
  82. package/src/lib/exec.ts +16 -0
  83. package/src/lib/parse.ts +29 -0
  84. package/src/lib/types.ts +110 -0
  85. package/src/notify/email.ts +68 -0
  86. package/src/notify/slack.ts +46 -0
  87. package/src/notify/telegram.ts +45 -0
  88. package/src/push/forge.ts +25 -0
  89. 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
+ });
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }