@glassmkr/crucible 0.6.1 → 0.6.3
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/.github/workflows/publish.yml +25 -0
- package/README.md +83 -45
- package/dist/__tests__/cli.test.d.ts +1 -0
- package/dist/__tests__/cli.test.js +64 -0
- package/dist/__tests__/cli.test.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +53 -0
- package/dist/cli.js.map +1 -0
- package/dist/collect/__tests__/ipmi.test.d.ts +1 -0
- package/dist/collect/__tests__/ipmi.test.js +90 -0
- package/dist/collect/__tests__/ipmi.test.js.map +1 -0
- package/dist/collect/__tests__/smart.test.d.ts +1 -0
- package/dist/collect/__tests__/smart.test.js +64 -0
- package/dist/collect/__tests__/smart.test.js.map +1 -0
- package/dist/collect/__tests__/zfs.test.d.ts +1 -0
- package/dist/collect/__tests__/zfs.test.js +68 -0
- package/dist/collect/__tests__/zfs.test.js.map +1 -0
- package/dist/collect/ipmi.d.ts +5 -1
- package/dist/collect/ipmi.js +6 -3
- package/dist/collect/ipmi.js.map +1 -1
- package/dist/collect/ntp.d.ts +1 -0
- package/dist/collect/ntp.js +39 -25
- package/dist/collect/ntp.js.map +1 -1
- package/dist/collect/smart.d.ts +26 -0
- package/dist/collect/smart.js +28 -25
- package/dist/collect/smart.js.map +1 -1
- package/dist/collect/zfs.d.ts +2 -1
- package/dist/collect/zfs.js +7 -3
- package/dist/collect/zfs.js.map +1 -1
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/__tests__/parse.test.d.ts +1 -0
- package/dist/lib/__tests__/parse.test.js +27 -0
- package/dist/lib/__tests__/parse.test.js.map +1 -0
- package/package.json +4 -2
- package/src/__tests__/cli.test.ts +74 -0
- package/src/cli.ts +62 -0
- package/src/collect/__tests__/ipmi.test.ts +96 -0
- package/src/collect/__tests__/smart.test.ts +68 -0
- package/src/collect/__tests__/zfs.test.ts +72 -0
- package/src/collect/ipmi.ts +6 -3
- package/src/collect/ntp.ts +42 -25
- package/src/collect/smart.ts +40 -28
- package/src/collect/zfs.ts +7 -2
- package/src/index.ts +13 -3
- package/src/lib/__tests__/parse.test.ts +28 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { classifySensor, deriveSelSeverity, parseSelTimestamp, parseFanStatus } from "../ipmi.js";
|
|
3
|
+
|
|
4
|
+
describe("classifySensor", () => {
|
|
5
|
+
it("recognizes memory sensors", () => {
|
|
6
|
+
expect(classifySensor("DIMM_A1")).toBe("memory");
|
|
7
|
+
expect(classifySensor("Memory ECC")).toBe("memory");
|
|
8
|
+
});
|
|
9
|
+
it("recognizes power supplies", () => {
|
|
10
|
+
expect(classifySensor("PSU1 Status")).toBe("power");
|
|
11
|
+
expect(classifySensor("Power Supply 1")).toBe("power");
|
|
12
|
+
});
|
|
13
|
+
it("recognizes fans, watchdog, processors, temps, voltage, storage, chassis", () => {
|
|
14
|
+
expect(classifySensor("Fan1")).toBe("fan");
|
|
15
|
+
expect(classifySensor("Watchdog")).toBe("watchdog");
|
|
16
|
+
expect(classifySensor("Processor 0")).toBe("processor");
|
|
17
|
+
// CPU-named temperature sensors classify as processor (cpu check wins over temp).
|
|
18
|
+
expect(classifySensor("CPU1 Temp")).toBe("processor");
|
|
19
|
+
expect(classifySensor("Inlet Temp")).toBe("temperature");
|
|
20
|
+
expect(classifySensor("VCore Voltage")).toBe("voltage");
|
|
21
|
+
expect(classifySensor("Drive Slot 1")).toBe("storage");
|
|
22
|
+
expect(classifySensor("Chassis Intrusion")).toBe("chassis");
|
|
23
|
+
});
|
|
24
|
+
it("falls back to 'other'", () => {
|
|
25
|
+
expect(classifySensor("Weird Sensor")).toBe("other");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("deriveSelSeverity", () => {
|
|
30
|
+
it("treats uncorrectable, thermal trip, AC lost as critical", () => {
|
|
31
|
+
expect(deriveSelSeverity("Uncorrectable ECC", "memory")).toBe("critical");
|
|
32
|
+
expect(deriveSelSeverity("Thermal trip", "processor")).toBe("critical");
|
|
33
|
+
expect(deriveSelSeverity("AC lost", "power")).toBe("critical");
|
|
34
|
+
expect(deriveSelSeverity("Machine check", "processor")).toBe("critical");
|
|
35
|
+
});
|
|
36
|
+
it("treats correctable ECC and redundancy lost as warning", () => {
|
|
37
|
+
expect(deriveSelSeverity("Correctable ECC", "memory")).toBe("warning");
|
|
38
|
+
expect(deriveSelSeverity("Redundancy lost", "power")).toBe("warning");
|
|
39
|
+
});
|
|
40
|
+
it("treats presence detected as info", () => {
|
|
41
|
+
expect(deriveSelSeverity("Presence detected", "memory")).toBe("info");
|
|
42
|
+
});
|
|
43
|
+
it("defaults to warning for memory/power/fan/processor sensor types", () => {
|
|
44
|
+
expect(deriveSelSeverity("Some odd event", "memory")).toBe("warning");
|
|
45
|
+
expect(deriveSelSeverity("Some odd event", "fan")).toBe("warning");
|
|
46
|
+
});
|
|
47
|
+
it("defaults to info for other sensor types", () => {
|
|
48
|
+
expect(deriveSelSeverity("Some odd event", "other")).toBe("info");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("parseSelTimestamp", () => {
|
|
53
|
+
it("formats a known date/time", () => {
|
|
54
|
+
expect(parseSelTimestamp("04/05/2026", "14:23:05")).toBe("2026-04-05T14:23:05Z");
|
|
55
|
+
});
|
|
56
|
+
it("pads single digit month/day", () => {
|
|
57
|
+
expect(parseSelTimestamp("4/5/2026", "09:00:00")).toBe("2026-04-05T09:00:00Z");
|
|
58
|
+
});
|
|
59
|
+
it("returns an ISO string for bad input (does not crash)", () => {
|
|
60
|
+
const out = parseSelTimestamp("", "");
|
|
61
|
+
expect(typeof out).toBe("string");
|
|
62
|
+
expect(out.length).toBeGreaterThan(10);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("parseFanStatus", () => {
|
|
67
|
+
it("parses healthy fan output", () => {
|
|
68
|
+
const raw = [
|
|
69
|
+
"FAN1 | 30h | ok | 7.1 | 5000 RPM",
|
|
70
|
+
"FAN2 | 31h | ok | 7.2 | 5100 RPM",
|
|
71
|
+
].join("\n");
|
|
72
|
+
const fans = parseFanStatus(raw);
|
|
73
|
+
expect(fans).toHaveLength(2);
|
|
74
|
+
expect(fans[0]).toMatchObject({ name: "FAN1", rpm: 5000, status: "ok" });
|
|
75
|
+
expect(fans[1].rpm).toBe(5100);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("marks critical fans (cr/nr) as critical", () => {
|
|
79
|
+
const raw = "FAN1 | 30h | cr | 7.1 | 0 RPM";
|
|
80
|
+
const fans = parseFanStatus(raw);
|
|
81
|
+
expect(fans[0].status).toBe("critical");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("marks absent/no-reading fans as absent", () => {
|
|
85
|
+
const raw = "FAN3 | 30h | ns | 7.1 | no reading";
|
|
86
|
+
const fans = parseFanStatus(raw);
|
|
87
|
+
expect(fans[0].status).toBe("absent");
|
|
88
|
+
expect(fans[0].rpm).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("treats 0 RPM with no explicit status as critical", () => {
|
|
92
|
+
const raw = "FAN1 | 30h | 7.1 | 0 RPM";
|
|
93
|
+
const fans = parseFanStatus(raw);
|
|
94
|
+
expect(fans[0].status).toBe("critical");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseSmartctlJson } from "../smart.js";
|
|
3
|
+
|
|
4
|
+
describe("parseSmartctlJson", () => {
|
|
5
|
+
it("parses a healthy SATA SSD", () => {
|
|
6
|
+
const data = {
|
|
7
|
+
model_name: "Samsung SSD 970 EVO 1TB",
|
|
8
|
+
smart_status: { passed: true },
|
|
9
|
+
temperature: { current: 38 },
|
|
10
|
+
power_on_time: { hours: 9000 },
|
|
11
|
+
ata_smart_attributes: {
|
|
12
|
+
table: [
|
|
13
|
+
{ id: 5, name: "Reallocated_Sector_Ct", raw: { value: 0 } },
|
|
14
|
+
{ id: 197, name: "Current_Pending_Sector", raw: { value: 0 } },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const info = parseSmartctlJson(data, "/dev/sda");
|
|
19
|
+
expect(info).toMatchObject({
|
|
20
|
+
device: "/dev/sda",
|
|
21
|
+
model: "Samsung SSD 970 EVO 1TB",
|
|
22
|
+
health: "PASSED",
|
|
23
|
+
temperature_c: 38,
|
|
24
|
+
power_on_hours: 9000,
|
|
25
|
+
reallocated_sectors: 0,
|
|
26
|
+
pending_sectors: 0,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses a failing SATA drive with reallocated sectors", () => {
|
|
31
|
+
const data = {
|
|
32
|
+
model_name: "WD Red 4TB",
|
|
33
|
+
smart_status: { passed: false },
|
|
34
|
+
ata_smart_attributes: {
|
|
35
|
+
table: [
|
|
36
|
+
{ id: 5, raw: { value: 12 } },
|
|
37
|
+
{ id: 197, raw: { value: 3 } },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const info = parseSmartctlJson(data, "/dev/sdb");
|
|
42
|
+
expect(info.health).toBe("FAILED");
|
|
43
|
+
expect(info.reallocated_sectors).toBe(12);
|
|
44
|
+
expect(info.pending_sectors).toBe(3);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("parses an NVMe drive with percentage_used", () => {
|
|
48
|
+
const data = {
|
|
49
|
+
model_name: "Samsung 980 PRO",
|
|
50
|
+
smart_status: { passed: true },
|
|
51
|
+
nvme_smart_health_information_log: { percentage_used: 22, temperature: 41 },
|
|
52
|
+
};
|
|
53
|
+
const info = parseSmartctlJson(data, "/dev/nvme0n1");
|
|
54
|
+
expect(info.percentage_used).toBe(22);
|
|
55
|
+
expect(info.temperature_c).toBe(41);
|
|
56
|
+
expect(info.health).toBe("PASSED");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("falls back to 'unknown' model when absent", () => {
|
|
60
|
+
const info = parseSmartctlJson({ smart_status: { passed: true } }, "/dev/sdc");
|
|
61
|
+
expect(info.model).toBe("unknown");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("treats missing smart_status as FAILED (safer default)", () => {
|
|
65
|
+
const info = parseSmartctlJson({}, "/dev/sdd");
|
|
66
|
+
expect(info.health).toBe("FAILED");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseZpoolStatus } from "../zfs.js";
|
|
3
|
+
|
|
4
|
+
describe("parseZpoolStatus", () => {
|
|
5
|
+
it("parses a healthy pool", () => {
|
|
6
|
+
const raw = ` pool: tank
|
|
7
|
+
state: ONLINE
|
|
8
|
+
scan: scrub repaired 0B in 01:23:45 with 0 errors on Sun Apr 5 12:34:56 2026
|
|
9
|
+
config:
|
|
10
|
+
|
|
11
|
+
NAME STATE READ WRITE CKSUM
|
|
12
|
+
tank ONLINE 0 0 0
|
|
13
|
+
mirror-0 ONLINE 0 0 0
|
|
14
|
+
|
|
15
|
+
errors: No known data errors
|
|
16
|
+
`;
|
|
17
|
+
const pools = parseZpoolStatus(raw);
|
|
18
|
+
expect(pools).toHaveLength(1);
|
|
19
|
+
expect(pools[0]).toMatchObject({
|
|
20
|
+
name: "tank",
|
|
21
|
+
state: "ONLINE",
|
|
22
|
+
errors_text: "No known data errors",
|
|
23
|
+
scrub_errors: 0,
|
|
24
|
+
scrub_repaired: "0B",
|
|
25
|
+
});
|
|
26
|
+
expect(pools[0].last_scrub_date).toContain("2026");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("parses a DEGRADED pool", () => {
|
|
30
|
+
const raw = ` pool: tank
|
|
31
|
+
state: DEGRADED
|
|
32
|
+
scan: scrub repaired 16K in 02:00:00 with 3 errors on Sun Apr 5 12:34:56 2026
|
|
33
|
+
|
|
34
|
+
errors: 3 data errors, use '-v' for a list
|
|
35
|
+
`;
|
|
36
|
+
const [p] = parseZpoolStatus(raw);
|
|
37
|
+
expect(p.state).toBe("DEGRADED");
|
|
38
|
+
expect(p.scrub_errors).toBe(3);
|
|
39
|
+
expect(p.scrub_repaired).toBe("16K");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("flags never-scrubbed pools", () => {
|
|
43
|
+
const raw = ` pool: tank
|
|
44
|
+
state: ONLINE
|
|
45
|
+
scan: none requested
|
|
46
|
+
|
|
47
|
+
errors: No known data errors
|
|
48
|
+
`;
|
|
49
|
+
const [p] = parseZpoolStatus(raw);
|
|
50
|
+
expect(p.scrub_never_run).toBe(true);
|
|
51
|
+
expect(p.scrub_errors).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns empty for no pools", () => {
|
|
55
|
+
expect(parseZpoolStatus("no pools available")).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("parses multiple pools", () => {
|
|
59
|
+
const raw = ` pool: tank
|
|
60
|
+
state: ONLINE
|
|
61
|
+
scan: none requested
|
|
62
|
+
errors: No known data errors
|
|
63
|
+
pool: data
|
|
64
|
+
state: FAULTED
|
|
65
|
+
scan: none requested
|
|
66
|
+
errors: 2 data errors
|
|
67
|
+
`;
|
|
68
|
+
const pools = parseZpoolStatus(raw);
|
|
69
|
+
expect(pools.map((p) => p.name)).toEqual(["tank", "data"]);
|
|
70
|
+
expect(pools[1].state).toBe("FAULTED");
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/collect/ipmi.ts
CHANGED
|
@@ -104,7 +104,7 @@ async function collectSelEvents(): Promise<SelEvent[]> {
|
|
|
104
104
|
return events.slice(-20).reverse();
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function parseSelTimestamp(date: string, time: string): string {
|
|
107
|
+
export function parseSelTimestamp(date: string, time: string): string {
|
|
108
108
|
if (!date || !time) return new Date().toISOString();
|
|
109
109
|
// Format: "04/05/2026" and "14:23:05"
|
|
110
110
|
const parts = date.split("/");
|
|
@@ -113,7 +113,7 @@ function parseSelTimestamp(date: string, time: string): string {
|
|
|
113
113
|
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}T${time}Z`;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
function classifySensor(sensor: string): string {
|
|
116
|
+
export function classifySensor(sensor: string): string {
|
|
117
117
|
const lower = sensor.toLowerCase();
|
|
118
118
|
if (lower.includes("memory") || lower.includes("dimm")) return "memory";
|
|
119
119
|
if (lower.includes("power supply") || lower.includes("psu")) return "power";
|
|
@@ -127,7 +127,7 @@ function classifySensor(sensor: string): string {
|
|
|
127
127
|
return "other";
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
function deriveSelSeverity(event: string, sensorType: string): string {
|
|
130
|
+
export function deriveSelSeverity(event: string, sensorType: string): string {
|
|
131
131
|
const lower = event.toLowerCase();
|
|
132
132
|
|
|
133
133
|
// Critical events
|
|
@@ -163,7 +163,10 @@ function deriveSelSeverity(event: string, sensorType: string): string {
|
|
|
163
163
|
async function collectFanStatus(): Promise<FanStatus[]> {
|
|
164
164
|
const output = await run("ipmitool", ["sdr", "type", "Fan"]);
|
|
165
165
|
if (!output) return [];
|
|
166
|
+
return parseFanStatus(output);
|
|
167
|
+
}
|
|
166
168
|
|
|
169
|
+
export function parseFanStatus(output: string): FanStatus[] {
|
|
167
170
|
const fans: FanStatus[] = [];
|
|
168
171
|
const lines = output.trim().split("\n");
|
|
169
172
|
|
package/src/collect/ntp.ts
CHANGED
|
@@ -5,16 +5,37 @@ export interface NtpData {
|
|
|
5
5
|
offset_seconds: number;
|
|
6
6
|
source: string;
|
|
7
7
|
daemon_running: boolean;
|
|
8
|
+
daemon_name: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Check whether a systemd unit is active. Returns false for missing units, not-found, etc.
|
|
12
|
+
async function isUnitActive(unit: string): Promise<boolean> {
|
|
13
|
+
const out = await run("systemctl", ["is-active", unit], 3000);
|
|
14
|
+
return out?.trim() === "active";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Detect which time-sync daemon unit is currently active on the host, if any.
|
|
18
|
+
// Returns "" when none are. We check the common names in order of preference.
|
|
19
|
+
async function detectActiveDaemon(): Promise<string> {
|
|
20
|
+
const candidates = ["chrony", "chronyd", "systemd-timesyncd", "ntp", "ntpsec", "ntpd"];
|
|
21
|
+
for (const unit of candidates) {
|
|
22
|
+
if (await isUnitActive(unit)) return unit;
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
8
25
|
}
|
|
9
26
|
|
|
10
27
|
export async function collectNtp(): Promise<NtpData> {
|
|
11
|
-
//
|
|
28
|
+
// The authoritative "is the daemon running" check is systemctl is-active,
|
|
29
|
+
// not any derived flag from timedatectl. This catches daemon crashes and
|
|
30
|
+
// manual stops where the kernel clock is still synced.
|
|
31
|
+
const daemonName = await detectActiveDaemon();
|
|
32
|
+
const daemonRunning = daemonName !== "";
|
|
33
|
+
|
|
34
|
+
// Try timedatectl first (works for systemd-timesyncd and records the kernel
|
|
35
|
+
// NTPSynchronized flag regardless of which daemon set it).
|
|
12
36
|
const tdctl = await run("timedatectl", ["show", "--property=NTPSynchronized", "--value"]);
|
|
13
|
-
// Only trust timedatectl if it returns a clear "yes" or "no"
|
|
14
37
|
if (tdctl !== null && (tdctl.trim() === "yes" || tdctl.trim() === "no")) {
|
|
15
38
|
const synced = tdctl.trim() === "yes";
|
|
16
|
-
const statusOut = await run("timedatectl", ["show", "--property=NTP", "--value"]);
|
|
17
|
-
const ntpEnabled = statusOut?.trim() === "yes";
|
|
18
39
|
|
|
19
40
|
let offset = 0;
|
|
20
41
|
try {
|
|
@@ -31,20 +52,17 @@ export async function collectNtp(): Promise<NtpData> {
|
|
|
31
52
|
}
|
|
32
53
|
} catch { /* offset stays 0 */ }
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
daemon_running: ntpEnabled || synced,
|
|
39
|
-
};
|
|
55
|
+
// Prefer an explicitly detected daemon name; fall back to systemd-timesyncd
|
|
56
|
+
// since timedatectl is most commonly the timesyncd frontend.
|
|
57
|
+
const source = daemonName || "systemd-timesyncd";
|
|
58
|
+
return { synced, offset_seconds: offset, source, daemon_running: daemonRunning, daemon_name: daemonName };
|
|
40
59
|
}
|
|
41
60
|
|
|
42
|
-
//
|
|
61
|
+
// Chrony tracking. Validate Leap status so we do not misread error text.
|
|
43
62
|
const chronyOut = await run("chronyc", ["tracking"]);
|
|
44
63
|
if (chronyOut) {
|
|
45
64
|
const leapMatch = chronyOut.match(/Leap status\s*:\s*(.+)/);
|
|
46
65
|
if (leapMatch) {
|
|
47
|
-
// Output looks like real chrony tracking data
|
|
48
66
|
const synced = leapMatch[1].trim() === "Normal";
|
|
49
67
|
let offset = 0;
|
|
50
68
|
const offsetMatch = chronyOut.match(/Last offset\s*:\s*([+-]?\d+\.?\d*)\s*seconds/);
|
|
@@ -52,20 +70,18 @@ export async function collectNtp(): Promise<NtpData> {
|
|
|
52
70
|
return {
|
|
53
71
|
synced,
|
|
54
72
|
offset_seconds: Math.abs(offset),
|
|
55
|
-
source: "chrony",
|
|
56
|
-
daemon_running:
|
|
73
|
+
source: daemonName || "chrony",
|
|
74
|
+
daemon_running: daemonRunning,
|
|
75
|
+
daemon_name: daemonName,
|
|
57
76
|
};
|
|
58
77
|
}
|
|
59
|
-
// chronyc returned output but not tracking data (error message, daemon not running)
|
|
60
|
-
// Fall through to ntpq
|
|
61
78
|
}
|
|
62
79
|
|
|
63
|
-
//
|
|
80
|
+
// ntpq peer table. Header check avoids false positives on error messages.
|
|
64
81
|
const ntpqOut = await run("ntpq", ["-pn"]);
|
|
65
82
|
if (ntpqOut) {
|
|
66
83
|
const hasHeader = ntpqOut.split("\n").some((line) => line.includes("remote"));
|
|
67
84
|
if (hasHeader) {
|
|
68
|
-
// Output looks like real ntpq peer table
|
|
69
85
|
const synced = ntpqOut.split("\n").some((line) => line.startsWith("*"));
|
|
70
86
|
let offset = 0;
|
|
71
87
|
const selectedLine = ntpqOut.split("\n").find((line) => line.startsWith("*"));
|
|
@@ -79,19 +95,20 @@ export async function collectNtp(): Promise<NtpData> {
|
|
|
79
95
|
return {
|
|
80
96
|
synced,
|
|
81
97
|
offset_seconds: offset,
|
|
82
|
-
source: "ntpd",
|
|
83
|
-
daemon_running:
|
|
98
|
+
source: daemonName || "ntpd",
|
|
99
|
+
daemon_running: daemonRunning,
|
|
100
|
+
daemon_name: daemonName,
|
|
84
101
|
};
|
|
85
102
|
}
|
|
86
|
-
// ntpq returned output but not peer table (error message, daemon not running)
|
|
87
|
-
// Fall through to "none"
|
|
88
103
|
}
|
|
89
104
|
|
|
90
|
-
// No
|
|
105
|
+
// No usable probe output. If systemd still reports a daemon as active, trust that;
|
|
106
|
+
// otherwise report fully down.
|
|
91
107
|
return {
|
|
92
108
|
synced: false,
|
|
93
109
|
offset_seconds: 0,
|
|
94
|
-
source: "none",
|
|
95
|
-
daemon_running:
|
|
110
|
+
source: daemonName || "none",
|
|
111
|
+
daemon_running: daemonRunning,
|
|
112
|
+
daemon_name: daemonName,
|
|
96
113
|
};
|
|
97
114
|
}
|
package/src/collect/smart.ts
CHANGED
|
@@ -22,34 +22,7 @@ export async function collectSmart(): Promise<SmartInfo[]> {
|
|
|
22
22
|
if (!output) continue;
|
|
23
23
|
|
|
24
24
|
try {
|
|
25
|
-
const
|
|
26
|
-
const info: SmartInfo = {
|
|
27
|
-
device,
|
|
28
|
-
model: data.model_name || data.model_family || "unknown",
|
|
29
|
-
health: data.smart_status?.passed ? "PASSED" : "FAILED",
|
|
30
|
-
temperature_c: data.temperature?.current,
|
|
31
|
-
power_on_hours: data.power_on_time?.hours,
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// NVMe specific
|
|
35
|
-
if (data.nvme_smart_health_information_log) {
|
|
36
|
-
const nvme = data.nvme_smart_health_information_log;
|
|
37
|
-
info.percentage_used = nvme.percentage_used;
|
|
38
|
-
info.temperature_c = nvme.temperature;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// SATA specific
|
|
42
|
-
if (data.ata_smart_attributes?.table) {
|
|
43
|
-
for (const attr of data.ata_smart_attributes.table) {
|
|
44
|
-
if (attr.id === 5 || attr.name === "Reallocated_Sector_Ct") {
|
|
45
|
-
info.reallocated_sectors = attr.raw?.value || 0;
|
|
46
|
-
}
|
|
47
|
-
if (attr.id === 197 || attr.name === "Current_Pending_Sector") {
|
|
48
|
-
info.pending_sectors = attr.raw?.value || 0;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
25
|
+
const info = parseSmartctlJson(JSON.parse(output), device);
|
|
53
26
|
results.push(info);
|
|
54
27
|
} catch {
|
|
55
28
|
// Failed to parse, skip this device
|
|
@@ -58,3 +31,42 @@ export async function collectSmart(): Promise<SmartInfo[]> {
|
|
|
58
31
|
|
|
59
32
|
return results;
|
|
60
33
|
}
|
|
34
|
+
|
|
35
|
+
export function parseSmartctlJson(data: Record<string, unknown> & {
|
|
36
|
+
model_name?: string;
|
|
37
|
+
model_family?: string;
|
|
38
|
+
smart_status?: { passed?: boolean };
|
|
39
|
+
temperature?: { current?: number };
|
|
40
|
+
power_on_time?: { hours?: number };
|
|
41
|
+
nvme_smart_health_information_log?: { percentage_used?: number; temperature?: number };
|
|
42
|
+
ata_smart_attributes?: { table?: Array<{ id?: number; name?: string; raw?: { value?: number } }> };
|
|
43
|
+
}, device: string): SmartInfo {
|
|
44
|
+
const info: SmartInfo = {
|
|
45
|
+
device,
|
|
46
|
+
model: data.model_name || data.model_family || "unknown",
|
|
47
|
+
health: data.smart_status?.passed ? "PASSED" : "FAILED",
|
|
48
|
+
temperature_c: data.temperature?.current,
|
|
49
|
+
power_on_hours: data.power_on_time?.hours,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// NVMe specific
|
|
53
|
+
if (data.nvme_smart_health_information_log) {
|
|
54
|
+
const nvme = data.nvme_smart_health_information_log;
|
|
55
|
+
info.percentage_used = nvme.percentage_used;
|
|
56
|
+
info.temperature_c = nvme.temperature;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// SATA specific
|
|
60
|
+
if (data.ata_smart_attributes?.table) {
|
|
61
|
+
for (const attr of data.ata_smart_attributes.table) {
|
|
62
|
+
if (attr.id === 5 || attr.name === "Reallocated_Sector_Ct") {
|
|
63
|
+
info.reallocated_sectors = attr.raw?.value || 0;
|
|
64
|
+
}
|
|
65
|
+
if (attr.id === 197 || attr.name === "Current_Pending_Sector") {
|
|
66
|
+
info.pending_sectors = attr.raw?.value || 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return info;
|
|
72
|
+
}
|
package/src/collect/zfs.ts
CHANGED
|
@@ -9,6 +9,12 @@ export async function collectZfs(): Promise<ZfsData | null> {
|
|
|
9
9
|
const zpoolStatus = await run("zpool", ["status"], 10000);
|
|
10
10
|
if (!zpoolStatus || !zpoolStatus.trim()) return null;
|
|
11
11
|
|
|
12
|
+
const pools = parseZpoolStatus(zpoolStatus);
|
|
13
|
+
if (pools.length === 0) return null;
|
|
14
|
+
return { pools };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseZpoolStatus(zpoolStatus: string): ZfsPool[] {
|
|
12
18
|
const pools: ZfsPool[] = [];
|
|
13
19
|
let current: ZfsPool | null = null;
|
|
14
20
|
|
|
@@ -56,6 +62,5 @@ export async function collectZfs(): Promise<ZfsData | null> {
|
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
return { pools };
|
|
65
|
+
return pools;
|
|
61
66
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { parseCliArgs } from "./cli.js";
|
|
7
7
|
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const PKG_VERSION = (() => {
|
|
@@ -14,6 +14,17 @@ const PKG_VERSION = (() => {
|
|
|
14
14
|
return "0.0.0";
|
|
15
15
|
}
|
|
16
16
|
})();
|
|
17
|
+
|
|
18
|
+
// Handle --version and --help before importing collectors, loading config, or
|
|
19
|
+
// starting the Prometheus server. This keeps the CLI responsive even on hosts
|
|
20
|
+
// missing the config file or external tools.
|
|
21
|
+
const { result: cliArgs, output: cliOutput } = parseCliArgs(process.argv.slice(2), PKG_VERSION);
|
|
22
|
+
if (cliArgs.mode !== "run") {
|
|
23
|
+
console.log(cliOutput);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
import { loadConfig } from "./config.js";
|
|
17
28
|
import { checkForUpdates } from "./lib/version-check.js";
|
|
18
29
|
import { startMetricsServer, updateMetrics } from "./metrics-server.js";
|
|
19
30
|
import { collectSystem } from "./collect/system.js";
|
|
@@ -41,8 +52,7 @@ import { collectNtp } from "./collect/ntp.js";
|
|
|
41
52
|
import { collectFileDescriptors } from "./collect/fd.js";
|
|
42
53
|
import type { Snapshot, IpmiInfo } from "./lib/types.js";
|
|
43
54
|
|
|
44
|
-
const
|
|
45
|
-
const config = loadConfig(configPath);
|
|
55
|
+
const config = loadConfig(cliArgs.configPath);
|
|
46
56
|
|
|
47
57
|
console.log(`[collector] Starting. Server: ${config.server_name}. Interval: ${config.collection.interval_seconds}s`);
|
|
48
58
|
console.log(`[collector] IPMI: ${config.collection.ipmi ? "enabled" : "disabled"}, SMART: ${config.collection.smart ? "enabled" : "disabled"}`);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseKeyValue, parseKb } from "../parse.js";
|
|
3
|
+
|
|
4
|
+
describe("parseKeyValue", () => {
|
|
5
|
+
it("parses colon-delimited key/value lines", () => {
|
|
6
|
+
const out = parseKeyValue("Name: foo\nVersion: 1.2.3\n");
|
|
7
|
+
expect(out).toEqual({ Name: "foo", Version: "1.2.3" });
|
|
8
|
+
});
|
|
9
|
+
it("ignores lines with no colon", () => {
|
|
10
|
+
expect(parseKeyValue("no colon here\nA: 1\n")).toEqual({ A: "1" });
|
|
11
|
+
});
|
|
12
|
+
it("trims whitespace around keys and values", () => {
|
|
13
|
+
expect(parseKeyValue(" A : 1 \n")).toEqual({ A: "1" });
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("parseKb", () => {
|
|
18
|
+
it("parses a numeric kB value", () => {
|
|
19
|
+
expect(parseKb("16384 kB")).toBe(16384);
|
|
20
|
+
});
|
|
21
|
+
it("parses without unit", () => {
|
|
22
|
+
expect(parseKb("4096")).toBe(4096);
|
|
23
|
+
});
|
|
24
|
+
it("returns 0 for undefined/bad input", () => {
|
|
25
|
+
expect(parseKb(undefined)).toBe(0);
|
|
26
|
+
expect(parseKb("not a number")).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
include: ["src/**/*.test.ts"],
|
|
6
|
+
exclude: ["node_modules", "dist"],
|
|
7
|
+
},
|
|
8
|
+
resolve: {
|
|
9
|
+
// .js specifier inside TS (NodeNext) needs to resolve to .ts in tests
|
|
10
|
+
extensions: [".ts", ".js"],
|
|
11
|
+
},
|
|
12
|
+
});
|