@glassmkr/crucible 0.6.1 → 0.6.2

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 (43) hide show
  1. package/.github/workflows/publish.yml +25 -0
  2. package/README.md +83 -45
  3. package/dist/__tests__/cli.test.d.ts +1 -0
  4. package/dist/__tests__/cli.test.js +64 -0
  5. package/dist/__tests__/cli.test.js.map +1 -0
  6. package/dist/cli.d.ts +10 -0
  7. package/dist/cli.js +53 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/collect/__tests__/ipmi.test.d.ts +1 -0
  10. package/dist/collect/__tests__/ipmi.test.js +90 -0
  11. package/dist/collect/__tests__/ipmi.test.js.map +1 -0
  12. package/dist/collect/__tests__/smart.test.d.ts +1 -0
  13. package/dist/collect/__tests__/smart.test.js +64 -0
  14. package/dist/collect/__tests__/smart.test.js.map +1 -0
  15. package/dist/collect/__tests__/zfs.test.d.ts +1 -0
  16. package/dist/collect/__tests__/zfs.test.js +68 -0
  17. package/dist/collect/__tests__/zfs.test.js.map +1 -0
  18. package/dist/collect/ipmi.d.ts +5 -1
  19. package/dist/collect/ipmi.js +6 -3
  20. package/dist/collect/ipmi.js.map +1 -1
  21. package/dist/collect/smart.d.ts +26 -0
  22. package/dist/collect/smart.js +28 -25
  23. package/dist/collect/smart.js.map +1 -1
  24. package/dist/collect/zfs.d.ts +2 -1
  25. package/dist/collect/zfs.js +7 -3
  26. package/dist/collect/zfs.js.map +1 -1
  27. package/dist/index.js +11 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/lib/__tests__/parse.test.d.ts +1 -0
  30. package/dist/lib/__tests__/parse.test.js +27 -0
  31. package/dist/lib/__tests__/parse.test.js.map +1 -0
  32. package/package.json +4 -2
  33. package/src/__tests__/cli.test.ts +74 -0
  34. package/src/cli.ts +62 -0
  35. package/src/collect/__tests__/ipmi.test.ts +96 -0
  36. package/src/collect/__tests__/smart.test.ts +68 -0
  37. package/src/collect/__tests__/zfs.test.ts +72 -0
  38. package/src/collect/ipmi.ts +6 -3
  39. package/src/collect/smart.ts +40 -28
  40. package/src/collect/zfs.ts +7 -2
  41. package/src/index.ts +13 -3
  42. package/src/lib/__tests__/parse.test.ts +28 -0
  43. package/vitest.config.ts +12 -0
@@ -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
+ });
@@ -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
 
@@ -22,34 +22,7 @@ export async function collectSmart(): Promise<SmartInfo[]> {
22
22
  if (!output) continue;
23
23
 
24
24
  try {
25
- const data = JSON.parse(output);
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
+ }
@@ -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
- if (pools.length === 0) return null;
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 { loadConfig } from "./config.js";
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 configPath = process.argv[2] || "/etc/glassmkr/collector.yaml";
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
+ });
@@ -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
+ });