@checkstack/collector-hardware-backend 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/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # @checkstack/collector-hardware-backend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f5b1f49: Added CPU, Disk, and Memory hardware collectors for SSH-based system monitoring.
8
+
9
+ - `CpuCollector`: Monitors CPU usage, load averages (1m, 5m, 15m), and core count
10
+ - `DiskCollector`: Monitors disk usage for configurable mount points
11
+ - `MemoryCollector`: Monitors RAM usage and optional swap metrics
12
+ - All collectors work via SSH transport for remote system monitoring
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [f5b1f49]
17
+ - Updated dependencies [f5b1f49]
18
+ - Updated dependencies [f5b1f49]
19
+ - Updated dependencies [f5b1f49]
20
+ - Updated dependencies [f5b1f49]
21
+ - @checkstack/backend-api@0.1.0
22
+ - @checkstack/healthcheck-common@0.1.0
23
+ - @checkstack/healthcheck-ssh-common@0.1.0
24
+ - @checkstack/common@0.0.3
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@checkstack/collector-hardware-backend",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0",
10
+ "test": "bun test"
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/backend-api": "workspace:*",
14
+ "@checkstack/common": "workspace:*",
15
+ "@checkstack/healthcheck-common": "workspace:*",
16
+ "@checkstack/healthcheck-ssh-common": "workspace:*"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "^1.0.0",
20
+ "typescript": "^5.0.0",
21
+ "@checkstack/tsconfig": "workspace:*",
22
+ "@checkstack/scripts": "workspace:*"
23
+ }
24
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { CpuCollector, type CpuConfig } from "./cpu";
3
+ import type { SshTransportClient } from "@checkstack/healthcheck-ssh-common";
4
+
5
+ describe("CpuCollector", () => {
6
+ const createMockClient = (
7
+ responses: {
8
+ stat1?: { stdout: string };
9
+ stat2?: { stdout: string };
10
+ loadavg?: { stdout: string };
11
+ nproc?: { stdout: string };
12
+ } = {}
13
+ ): SshTransportClient => {
14
+ let callCount = 0;
15
+ return {
16
+ exec: mock((cmd: string) => {
17
+ if (cmd.includes("/proc/stat")) {
18
+ callCount++;
19
+ if (callCount === 1) {
20
+ return Promise.resolve({
21
+ exitCode: 0,
22
+ stdout:
23
+ responses.stat1?.stdout ?? "cpu 100 0 50 800 50 0 0 0 0 0",
24
+ stderr: "",
25
+ });
26
+ }
27
+ return Promise.resolve({
28
+ exitCode: 0,
29
+ stdout: responses.stat2?.stdout ?? "cpu 150 0 100 850 50 0 0 0 0 0",
30
+ stderr: "",
31
+ });
32
+ }
33
+ if (cmd.includes("/proc/loadavg")) {
34
+ return Promise.resolve({
35
+ exitCode: 0,
36
+ stdout: responses.loadavg?.stdout ?? "0.50 0.75 1.00 1/100 12345",
37
+ stderr: "",
38
+ });
39
+ }
40
+ if (cmd.includes("nproc")) {
41
+ return Promise.resolve({
42
+ exitCode: 0,
43
+ stdout: responses.nproc?.stdout ?? "4\n",
44
+ stderr: "",
45
+ });
46
+ }
47
+ return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" });
48
+ }),
49
+ };
50
+ };
51
+
52
+ describe("execute", () => {
53
+ it("should collect CPU usage", async () => {
54
+ const collector = new CpuCollector();
55
+ const client = createMockClient();
56
+
57
+ const result = await collector.execute({
58
+ config: { includeLoadAverage: false, includeCoreCount: false },
59
+ client,
60
+ pluginId: "test",
61
+ });
62
+
63
+ expect(result.result.usagePercent).toBeDefined();
64
+ expect(result.result.usagePercent).toBeGreaterThanOrEqual(0);
65
+ expect(result.result.usagePercent).toBeLessThanOrEqual(100);
66
+ });
67
+
68
+ it("should include load averages when configured", async () => {
69
+ const collector = new CpuCollector();
70
+ const client = createMockClient({
71
+ loadavg: { stdout: "1.50 2.00 3.00 1/200 12345" },
72
+ });
73
+
74
+ const result = await collector.execute({
75
+ config: { includeLoadAverage: true, includeCoreCount: false },
76
+ client,
77
+ pluginId: "test",
78
+ });
79
+
80
+ expect(result.result.loadAvg1m).toBe(1.5);
81
+ expect(result.result.loadAvg5m).toBe(2);
82
+ expect(result.result.loadAvg15m).toBe(3);
83
+ });
84
+
85
+ it("should include core count when configured", async () => {
86
+ const collector = new CpuCollector();
87
+ const client = createMockClient({ nproc: { stdout: "8\n" } });
88
+
89
+ const result = await collector.execute({
90
+ config: { includeLoadAverage: false, includeCoreCount: true },
91
+ client,
92
+ pluginId: "test",
93
+ });
94
+
95
+ expect(result.result.coreCount).toBe(8);
96
+ });
97
+ });
98
+
99
+ describe("aggregateResult", () => {
100
+ it("should calculate average and max CPU usage", () => {
101
+ const collector = new CpuCollector();
102
+ const runs = [
103
+ {
104
+ id: "1",
105
+ status: "healthy" as const,
106
+ latencyMs: 100,
107
+ checkId: "c1",
108
+ timestamp: new Date(),
109
+ metadata: { usagePercent: 25, loadAvg1m: 1.0 },
110
+ },
111
+ {
112
+ id: "2",
113
+ status: "healthy" as const,
114
+ latencyMs: 100,
115
+ checkId: "c1",
116
+ timestamp: new Date(),
117
+ metadata: { usagePercent: 75, loadAvg1m: 2.0 },
118
+ },
119
+ ];
120
+
121
+ const aggregated = collector.aggregateResult(runs);
122
+
123
+ expect(aggregated.avgUsagePercent).toBe(50);
124
+ expect(aggregated.maxUsagePercent).toBe(75);
125
+ expect(aggregated.avgLoadAvg1m).toBe(1.5);
126
+ });
127
+ });
128
+
129
+ describe("metadata", () => {
130
+ it("should have correct static properties", () => {
131
+ const collector = new CpuCollector();
132
+
133
+ expect(collector.id).toBe("cpu");
134
+ expect(collector.displayName).toBe("CPU Metrics");
135
+ expect(collector.supportedPlugins).toHaveLength(1);
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,199 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import { healthResultNumber } from "@checkstack/healthcheck-common";
9
+ import {
10
+ pluginMetadata as sshPluginMetadata,
11
+ type SshTransportClient,
12
+ } from "@checkstack/healthcheck-ssh-common";
13
+
14
+ // ============================================================================
15
+ // CONFIGURATION SCHEMA
16
+ // ============================================================================
17
+
18
+ const cpuConfigSchema = z.object({
19
+ includeLoadAverage: z
20
+ .boolean()
21
+ .default(true)
22
+ .describe("Include 1m, 5m, 15m load averages"),
23
+ includeCoreCount: z
24
+ .boolean()
25
+ .default(true)
26
+ .describe("Include number of CPU cores"),
27
+ });
28
+
29
+ export type CpuConfig = z.infer<typeof cpuConfigSchema>;
30
+
31
+ // ============================================================================
32
+ // RESULT SCHEMAS
33
+ // ============================================================================
34
+
35
+ const cpuResultSchema = z.object({
36
+ usagePercent: healthResultNumber({
37
+ "x-chart-type": "line",
38
+ "x-chart-label": "CPU Usage",
39
+ "x-chart-unit": "%",
40
+ }),
41
+ loadAvg1m: healthResultNumber({
42
+ "x-chart-type": "line",
43
+ "x-chart-label": "Load (1m)",
44
+ }).optional(),
45
+ loadAvg5m: healthResultNumber({
46
+ "x-chart-type": "line",
47
+ "x-chart-label": "Load (5m)",
48
+ }).optional(),
49
+ loadAvg15m: healthResultNumber({
50
+ "x-chart-type": "line",
51
+ "x-chart-label": "Load (15m)",
52
+ }).optional(),
53
+ coreCount: healthResultNumber({
54
+ "x-chart-type": "counter",
55
+ "x-chart-label": "CPU Cores",
56
+ }).optional(),
57
+ });
58
+
59
+ export type CpuResult = z.infer<typeof cpuResultSchema>;
60
+
61
+ const cpuAggregatedSchema = z.object({
62
+ avgUsagePercent: healthResultNumber({
63
+ "x-chart-type": "line",
64
+ "x-chart-label": "Avg CPU Usage",
65
+ "x-chart-unit": "%",
66
+ }),
67
+ maxUsagePercent: healthResultNumber({
68
+ "x-chart-type": "line",
69
+ "x-chart-label": "Max CPU Usage",
70
+ "x-chart-unit": "%",
71
+ }),
72
+ avgLoadAvg1m: healthResultNumber({
73
+ "x-chart-type": "line",
74
+ "x-chart-label": "Avg Load (1m)",
75
+ }),
76
+ });
77
+
78
+ export type CpuAggregatedResult = z.infer<typeof cpuAggregatedSchema>;
79
+
80
+ // ============================================================================
81
+ // CPU COLLECTOR
82
+ // ============================================================================
83
+
84
+ export class CpuCollector
85
+ implements
86
+ CollectorStrategy<
87
+ SshTransportClient,
88
+ CpuConfig,
89
+ CpuResult,
90
+ CpuAggregatedResult
91
+ >
92
+ {
93
+ id = "cpu";
94
+ displayName = "CPU Metrics";
95
+ description = "Collects CPU usage, load averages, and core count via SSH";
96
+
97
+ supportedPlugins = [sshPluginMetadata];
98
+
99
+ config = new Versioned({ version: 1, schema: cpuConfigSchema });
100
+ result = new Versioned({ version: 1, schema: cpuResultSchema });
101
+ aggregatedResult = new Versioned({ version: 1, schema: cpuAggregatedSchema });
102
+
103
+ async execute({
104
+ config,
105
+ client,
106
+ }: {
107
+ config: CpuConfig;
108
+ client: SshTransportClient;
109
+ pluginId: string;
110
+ }): Promise<CollectorResult<CpuResult>> {
111
+ // Get CPU usage from /proc/stat (two samples to calculate delta)
112
+ const stat1 = await client.exec("cat /proc/stat | head -1");
113
+ await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms sample
114
+ const stat2 = await client.exec("cat /proc/stat | head -1");
115
+
116
+ const usagePercent = this.calculateCpuUsage(stat1.stdout, stat2.stdout);
117
+
118
+ const result: CpuResult = { usagePercent };
119
+
120
+ // Get load averages
121
+ if (config.includeLoadAverage) {
122
+ const uptime = await client.exec("cat /proc/loadavg");
123
+ const loads = this.parseLoadAvg(uptime.stdout);
124
+ result.loadAvg1m = loads.load1m;
125
+ result.loadAvg5m = loads.load5m;
126
+ result.loadAvg15m = loads.load15m;
127
+ }
128
+
129
+ // Get core count
130
+ if (config.includeCoreCount) {
131
+ const nproc = await client.exec("nproc");
132
+ result.coreCount = Number.parseInt(nproc.stdout.trim(), 10) || undefined;
133
+ }
134
+
135
+ return { result };
136
+ }
137
+
138
+ aggregateResult(
139
+ runs: HealthCheckRunForAggregation<CpuResult>[]
140
+ ): CpuAggregatedResult {
141
+ const usages = runs
142
+ .map((r) => r.metadata?.usagePercent)
143
+ .filter((v): v is number => typeof v === "number");
144
+
145
+ const loads = runs
146
+ .map((r) => r.metadata?.loadAvg1m)
147
+ .filter((v): v is number => typeof v === "number");
148
+
149
+ return {
150
+ avgUsagePercent: usages.length > 0 ? this.avg(usages) : 0,
151
+ maxUsagePercent: usages.length > 0 ? Math.max(...usages) : 0,
152
+ avgLoadAvg1m: loads.length > 0 ? this.avg(loads) : 0,
153
+ };
154
+ }
155
+
156
+ // ============================================================================
157
+ // PARSING HELPERS
158
+ // ============================================================================
159
+
160
+ private parseCpuStat(line: string): { idle: number; total: number } {
161
+ // Format: cpu user nice system idle iowait irq softirq steal guest guest_nice
162
+ const parts = line.trim().split(/\s+/).slice(1).map(Number);
163
+ const idle = parts[3] + parts[4]; // idle + iowait
164
+ const total = parts.reduce((a, b) => a + b, 0);
165
+ return { idle, total };
166
+ }
167
+
168
+ private calculateCpuUsage(stat1: string, stat2: string): number {
169
+ const s1 = this.parseCpuStat(stat1);
170
+ const s2 = this.parseCpuStat(stat2);
171
+
172
+ const idleDelta = s2.idle - s1.idle;
173
+ const totalDelta = s2.total - s1.total;
174
+
175
+ if (totalDelta === 0) return 0;
176
+
177
+ return Math.round(((totalDelta - idleDelta) / totalDelta) * 100 * 10) / 10;
178
+ }
179
+
180
+ private parseLoadAvg(output: string): {
181
+ load1m?: number;
182
+ load5m?: number;
183
+ load15m?: number;
184
+ } {
185
+ // Format: 0.00 0.01 0.05 1/234 5678
186
+ const parts = output.trim().split(/\s+/);
187
+ return {
188
+ load1m: Number.parseFloat(parts[0]) || undefined,
189
+ load5m: Number.parseFloat(parts[1]) || undefined,
190
+ load15m: Number.parseFloat(parts[2]) || undefined,
191
+ };
192
+ }
193
+
194
+ private avg(nums: number[]): number {
195
+ return (
196
+ Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10
197
+ );
198
+ }
199
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { DiskCollector, type DiskConfig } from "./disk";
3
+ import type { SshTransportClient } from "@checkstack/healthcheck-ssh-common";
4
+
5
+ describe("DiskCollector", () => {
6
+ const createMockClient = (
7
+ dfOutput: string = "/dev/sda1 100G 45G 55G 45% /"
8
+ ): SshTransportClient => ({
9
+ exec: mock(() =>
10
+ Promise.resolve({
11
+ exitCode: 0,
12
+ stdout: dfOutput,
13
+ stderr: "",
14
+ })
15
+ ),
16
+ });
17
+
18
+ describe("execute", () => {
19
+ it("should collect disk usage for mount point", async () => {
20
+ const collector = new DiskCollector();
21
+ const client = createMockClient("/dev/sda1 100G 45G 55G 45% /");
22
+
23
+ const result = await collector.execute({
24
+ config: { mountPoint: "/" },
25
+ client,
26
+ pluginId: "test",
27
+ });
28
+
29
+ expect(result.result.filesystem).toBe("/dev/sda1");
30
+ expect(result.result.totalGb).toBe(100);
31
+ expect(result.result.usedGb).toBe(45);
32
+ expect(result.result.availableGb).toBe(55);
33
+ expect(result.result.usedPercent).toBe(45);
34
+ expect(result.result.mountPoint).toBe("/");
35
+ });
36
+
37
+ it("should handle different mount points", async () => {
38
+ const collector = new DiskCollector();
39
+ const client = createMockClient(
40
+ "/dev/sdb1 500G 200G 300G 40% /data"
41
+ );
42
+
43
+ const result = await collector.execute({
44
+ config: { mountPoint: "/data" },
45
+ client,
46
+ pluginId: "test",
47
+ });
48
+
49
+ expect(result.result.filesystem).toBe("/dev/sdb1");
50
+ expect(result.result.totalGb).toBe(500);
51
+ expect(result.result.usedPercent).toBe(40);
52
+ expect(result.result.mountPoint).toBe("/data");
53
+ });
54
+
55
+ it("should pass mount point to df command", async () => {
56
+ const collector = new DiskCollector();
57
+ const client = createMockClient();
58
+
59
+ await collector.execute({
60
+ config: { mountPoint: "/var" },
61
+ client,
62
+ pluginId: "test",
63
+ });
64
+
65
+ expect(client.exec).toHaveBeenCalledWith("df -BG /var | tail -1");
66
+ });
67
+ });
68
+
69
+ describe("aggregateResult", () => {
70
+ it("should calculate average and max disk usage", () => {
71
+ const collector = new DiskCollector();
72
+ const runs = [
73
+ {
74
+ id: "1",
75
+ status: "healthy" as const,
76
+ latencyMs: 100,
77
+ checkId: "c1",
78
+ timestamp: new Date(),
79
+ metadata: {
80
+ filesystem: "/dev/sda1",
81
+ totalGb: 100,
82
+ usedGb: 30,
83
+ availableGb: 70,
84
+ usedPercent: 30,
85
+ mountPoint: "/",
86
+ },
87
+ },
88
+ {
89
+ id: "2",
90
+ status: "healthy" as const,
91
+ latencyMs: 100,
92
+ checkId: "c1",
93
+ timestamp: new Date(),
94
+ metadata: {
95
+ filesystem: "/dev/sda1",
96
+ totalGb: 100,
97
+ usedGb: 50,
98
+ availableGb: 50,
99
+ usedPercent: 50,
100
+ mountPoint: "/",
101
+ },
102
+ },
103
+ ];
104
+
105
+ const aggregated = collector.aggregateResult(runs);
106
+
107
+ expect(aggregated.avgUsedPercent).toBe(40);
108
+ expect(aggregated.maxUsedPercent).toBe(50);
109
+ });
110
+ });
111
+
112
+ describe("metadata", () => {
113
+ it("should have correct static properties", () => {
114
+ const collector = new DiskCollector();
115
+
116
+ expect(collector.id).toBe("disk");
117
+ expect(collector.displayName).toBe("Disk Metrics");
118
+ expect(collector.supportedPlugins).toHaveLength(1);
119
+ });
120
+ });
121
+ });
@@ -0,0 +1,171 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import {
9
+ healthResultNumber,
10
+ healthResultString,
11
+ } from "@checkstack/healthcheck-common";
12
+ import {
13
+ pluginMetadata as sshPluginMetadata,
14
+ type SshTransportClient,
15
+ } from "@checkstack/healthcheck-ssh-common";
16
+
17
+ // ============================================================================
18
+ // CONFIGURATION SCHEMA
19
+ // ============================================================================
20
+
21
+ const diskConfigSchema = z.object({
22
+ mountPoint: z
23
+ .string()
24
+ .default("/")
25
+ .describe("Mount point to monitor (e.g., /, /home, /var)"),
26
+ });
27
+
28
+ export type DiskConfig = z.infer<typeof diskConfigSchema>;
29
+
30
+ // ============================================================================
31
+ // RESULT SCHEMAS
32
+ // ============================================================================
33
+
34
+ const diskResultSchema = z.object({
35
+ filesystem: healthResultString({
36
+ "x-chart-type": "text",
37
+ "x-chart-label": "Filesystem",
38
+ }),
39
+ totalGb: healthResultNumber({
40
+ "x-chart-type": "counter",
41
+ "x-chart-label": "Total Disk",
42
+ "x-chart-unit": "GB",
43
+ }),
44
+ usedGb: healthResultNumber({
45
+ "x-chart-type": "line",
46
+ "x-chart-label": "Used Disk",
47
+ "x-chart-unit": "GB",
48
+ }),
49
+ availableGb: healthResultNumber({
50
+ "x-chart-type": "line",
51
+ "x-chart-label": "Available Disk",
52
+ "x-chart-unit": "GB",
53
+ }),
54
+ usedPercent: healthResultNumber({
55
+ "x-chart-type": "gauge",
56
+ "x-chart-label": "Disk Usage",
57
+ "x-chart-unit": "%",
58
+ }),
59
+ mountPoint: healthResultString({
60
+ "x-chart-type": "text",
61
+ "x-chart-label": "Mount Point",
62
+ }),
63
+ });
64
+
65
+ export type DiskResult = z.infer<typeof diskResultSchema>;
66
+
67
+ const diskAggregatedSchema = z.object({
68
+ avgUsedPercent: healthResultNumber({
69
+ "x-chart-type": "line",
70
+ "x-chart-label": "Avg Disk Usage",
71
+ "x-chart-unit": "%",
72
+ }),
73
+ maxUsedPercent: healthResultNumber({
74
+ "x-chart-type": "line",
75
+ "x-chart-label": "Max Disk Usage",
76
+ "x-chart-unit": "%",
77
+ }),
78
+ });
79
+
80
+ export type DiskAggregatedResult = z.infer<typeof diskAggregatedSchema>;
81
+
82
+ // ============================================================================
83
+ // DISK COLLECTOR
84
+ // ============================================================================
85
+
86
+ export class DiskCollector
87
+ implements
88
+ CollectorStrategy<
89
+ SshTransportClient,
90
+ DiskConfig,
91
+ DiskResult,
92
+ DiskAggregatedResult
93
+ >
94
+ {
95
+ id = "disk";
96
+ displayName = "Disk Metrics";
97
+ description = "Collects disk usage for a specific mount point via SSH";
98
+
99
+ supportedPlugins = [sshPluginMetadata];
100
+
101
+ config = new Versioned({ version: 1, schema: diskConfigSchema });
102
+ result = new Versioned({ version: 1, schema: diskResultSchema });
103
+ aggregatedResult = new Versioned({
104
+ version: 1,
105
+ schema: diskAggregatedSchema,
106
+ });
107
+
108
+ async execute({
109
+ config,
110
+ client,
111
+ }: {
112
+ config: DiskConfig;
113
+ client: SshTransportClient;
114
+ pluginId: string;
115
+ }): Promise<CollectorResult<DiskResult>> {
116
+ // Use df with specific mount point, output in 1G blocks
117
+ const dfResult = await client.exec(`df -BG ${config.mountPoint} | tail -1`);
118
+ const parsed = this.parseDfOutput(dfResult.stdout, config.mountPoint);
119
+
120
+ return { result: parsed };
121
+ }
122
+
123
+ aggregateResult(
124
+ runs: HealthCheckRunForAggregation<DiskResult>[]
125
+ ): DiskAggregatedResult {
126
+ const usedPercents = runs
127
+ .map((r) => r.metadata?.usedPercent)
128
+ .filter((v): v is number => typeof v === "number");
129
+
130
+ return {
131
+ avgUsedPercent: usedPercents.length > 0 ? this.avg(usedPercents) : 0,
132
+ maxUsedPercent: usedPercents.length > 0 ? Math.max(...usedPercents) : 0,
133
+ };
134
+ }
135
+
136
+ // ============================================================================
137
+ // PARSING HELPERS
138
+ // ============================================================================
139
+
140
+ private parseGb(val: string): number {
141
+ // Remove 'G' suffix and parse
142
+ return Number.parseInt(val.replace(/G$/i, ""), 10) || 0;
143
+ }
144
+
145
+ private parseDfOutput(output: string, mountPoint: string): DiskResult {
146
+ // Format: Filesystem 1G-blocks Used Available Use% Mounted on
147
+ // /dev/sda1 100G 45G 55G 45% /
148
+ const parts = output.trim().split(/\s+/);
149
+
150
+ const filesystem = parts[0] || "unknown";
151
+ const totalGb = this.parseGb(parts[1]);
152
+ const usedGb = this.parseGb(parts[2]);
153
+ const availableGb = this.parseGb(parts[3]);
154
+ const usedPercent = Number.parseInt(parts[4]?.replace(/%$/, ""), 10) || 0;
155
+
156
+ return {
157
+ filesystem,
158
+ totalGb,
159
+ usedGb,
160
+ availableGb,
161
+ usedPercent,
162
+ mountPoint,
163
+ };
164
+ }
165
+
166
+ private avg(nums: number[]): number {
167
+ return (
168
+ Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10
169
+ );
170
+ }
171
+ }
@@ -0,0 +1,12 @@
1
+ export { CpuCollector } from "./cpu";
2
+ export type { CpuConfig, CpuResult, CpuAggregatedResult } from "./cpu";
3
+
4
+ export { MemoryCollector } from "./memory";
5
+ export type {
6
+ MemoryConfig,
7
+ MemoryResult,
8
+ MemoryAggregatedResult,
9
+ } from "./memory";
10
+
11
+ export { DiskCollector } from "./disk";
12
+ export type { DiskConfig, DiskResult, DiskAggregatedResult } from "./disk";
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { MemoryCollector, type MemoryConfig } from "./memory";
3
+ import type { SshTransportClient } from "@checkstack/healthcheck-ssh-common";
4
+
5
+ describe("MemoryCollector", () => {
6
+ const createMockClient = (
7
+ freeOutput: string = ` total used free shared buff/cache available
8
+ Mem: 16000 4000 8000 500 4000 12000
9
+ Swap: 4096 512 3584`
10
+ ): SshTransportClient => ({
11
+ exec: mock(() =>
12
+ Promise.resolve({
13
+ exitCode: 0,
14
+ stdout: freeOutput,
15
+ stderr: "",
16
+ })
17
+ ),
18
+ });
19
+
20
+ describe("execute", () => {
21
+ it("should collect memory usage", async () => {
22
+ const collector = new MemoryCollector();
23
+ const client = createMockClient();
24
+
25
+ const result = await collector.execute({
26
+ config: { includeSwap: false, includeBuffersCache: false },
27
+ client,
28
+ pluginId: "test",
29
+ });
30
+
31
+ expect(result.result.totalMb).toBe(16000);
32
+ expect(result.result.usedMb).toBe(4000);
33
+ expect(result.result.freeMb).toBe(8000);
34
+ expect(result.result.usedPercent).toBe(25);
35
+ });
36
+
37
+ it("should include swap when configured", async () => {
38
+ const collector = new MemoryCollector();
39
+ const client = createMockClient();
40
+
41
+ const result = await collector.execute({
42
+ config: { includeSwap: true, includeBuffersCache: false },
43
+ client,
44
+ pluginId: "test",
45
+ });
46
+
47
+ expect(result.result.swapTotalMb).toBe(4096);
48
+ expect(result.result.swapUsedMb).toBe(512);
49
+ });
50
+
51
+ it("should not include swap when not configured", async () => {
52
+ const collector = new MemoryCollector();
53
+ const client = createMockClient();
54
+
55
+ const result = await collector.execute({
56
+ config: { includeSwap: false, includeBuffersCache: false },
57
+ client,
58
+ pluginId: "test",
59
+ });
60
+
61
+ expect(result.result.swapTotalMb).toBeUndefined();
62
+ expect(result.result.swapUsedMb).toBeUndefined();
63
+ });
64
+
65
+ it("should call free -m command", async () => {
66
+ const collector = new MemoryCollector();
67
+ const client = createMockClient();
68
+
69
+ await collector.execute({
70
+ config: { includeSwap: false, includeBuffersCache: false },
71
+ client,
72
+ pluginId: "test",
73
+ });
74
+
75
+ expect(client.exec).toHaveBeenCalledWith("free -m");
76
+ });
77
+ });
78
+
79
+ describe("aggregateResult", () => {
80
+ it("should calculate average and max memory usage", () => {
81
+ const collector = new MemoryCollector();
82
+ const runs = [
83
+ {
84
+ id: "1",
85
+ status: "healthy" as const,
86
+ latencyMs: 100,
87
+ checkId: "c1",
88
+ timestamp: new Date(),
89
+ metadata: {
90
+ totalMb: 16000,
91
+ usedMb: 4000,
92
+ freeMb: 12000,
93
+ usedPercent: 25,
94
+ },
95
+ },
96
+ {
97
+ id: "2",
98
+ status: "healthy" as const,
99
+ latencyMs: 100,
100
+ checkId: "c1",
101
+ timestamp: new Date(),
102
+ metadata: {
103
+ totalMb: 16000,
104
+ usedMb: 12000,
105
+ freeMb: 4000,
106
+ usedPercent: 75,
107
+ },
108
+ },
109
+ ];
110
+
111
+ const aggregated = collector.aggregateResult(runs);
112
+
113
+ expect(aggregated.avgUsedPercent).toBe(50);
114
+ expect(aggregated.maxUsedPercent).toBe(75);
115
+ expect(aggregated.avgUsedMb).toBe(8000);
116
+ });
117
+ });
118
+
119
+ describe("metadata", () => {
120
+ it("should have correct static properties", () => {
121
+ const collector = new MemoryCollector();
122
+
123
+ expect(collector.id).toBe("memory");
124
+ expect(collector.displayName).toBe("Memory Metrics");
125
+ expect(collector.supportedPlugins).toHaveLength(1);
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,206 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import { healthResultNumber } from "@checkstack/healthcheck-common";
9
+ import {
10
+ pluginMetadata as sshPluginMetadata,
11
+ type SshTransportClient,
12
+ } from "@checkstack/healthcheck-ssh-common";
13
+
14
+ // ============================================================================
15
+ // CONFIGURATION SCHEMA
16
+ // ============================================================================
17
+
18
+ const memoryConfigSchema = z.object({
19
+ includeSwap: z
20
+ .boolean()
21
+ .default(true)
22
+ .describe("Include swap usage in results"),
23
+ includeBuffersCache: z
24
+ .boolean()
25
+ .default(false)
26
+ .describe("Include buffers/cache breakdown"),
27
+ });
28
+
29
+ export type MemoryConfig = z.infer<typeof memoryConfigSchema>;
30
+
31
+ // ============================================================================
32
+ // RESULT SCHEMAS
33
+ // ============================================================================
34
+
35
+ const memoryResultSchema = z.object({
36
+ totalMb: healthResultNumber({
37
+ "x-chart-type": "counter",
38
+ "x-chart-label": "Total Memory",
39
+ "x-chart-unit": "MB",
40
+ }),
41
+ usedMb: healthResultNumber({
42
+ "x-chart-type": "line",
43
+ "x-chart-label": "Used Memory",
44
+ "x-chart-unit": "MB",
45
+ }),
46
+ freeMb: healthResultNumber({
47
+ "x-chart-type": "line",
48
+ "x-chart-label": "Free Memory",
49
+ "x-chart-unit": "MB",
50
+ }),
51
+ usedPercent: healthResultNumber({
52
+ "x-chart-type": "gauge",
53
+ "x-chart-label": "Memory Usage",
54
+ "x-chart-unit": "%",
55
+ }),
56
+ swapUsedMb: healthResultNumber({
57
+ "x-chart-type": "line",
58
+ "x-chart-label": "Swap Used",
59
+ "x-chart-unit": "MB",
60
+ }).optional(),
61
+ swapTotalMb: healthResultNumber({
62
+ "x-chart-type": "counter",
63
+ "x-chart-label": "Swap Total",
64
+ "x-chart-unit": "MB",
65
+ }).optional(),
66
+ });
67
+
68
+ export type MemoryResult = z.infer<typeof memoryResultSchema>;
69
+
70
+ const memoryAggregatedSchema = z.object({
71
+ avgUsedPercent: healthResultNumber({
72
+ "x-chart-type": "line",
73
+ "x-chart-label": "Avg Memory Usage",
74
+ "x-chart-unit": "%",
75
+ }),
76
+ maxUsedPercent: healthResultNumber({
77
+ "x-chart-type": "line",
78
+ "x-chart-label": "Max Memory Usage",
79
+ "x-chart-unit": "%",
80
+ }),
81
+ avgUsedMb: healthResultNumber({
82
+ "x-chart-type": "line",
83
+ "x-chart-label": "Avg Memory Used",
84
+ "x-chart-unit": "MB",
85
+ }),
86
+ });
87
+
88
+ export type MemoryAggregatedResult = z.infer<typeof memoryAggregatedSchema>;
89
+
90
+ // ============================================================================
91
+ // MEMORY COLLECTOR
92
+ // ============================================================================
93
+
94
+ export class MemoryCollector
95
+ implements
96
+ CollectorStrategy<
97
+ SshTransportClient,
98
+ MemoryConfig,
99
+ MemoryResult,
100
+ MemoryAggregatedResult
101
+ >
102
+ {
103
+ id = "memory";
104
+ displayName = "Memory Metrics";
105
+ description = "Collects RAM and swap usage via SSH";
106
+
107
+ supportedPlugins = [sshPluginMetadata];
108
+
109
+ config = new Versioned({ version: 1, schema: memoryConfigSchema });
110
+ result = new Versioned({ version: 1, schema: memoryResultSchema });
111
+ aggregatedResult = new Versioned({
112
+ version: 1,
113
+ schema: memoryAggregatedSchema,
114
+ });
115
+
116
+ async execute({
117
+ config,
118
+ client,
119
+ }: {
120
+ config: MemoryConfig;
121
+ client: SshTransportClient;
122
+ pluginId: string;
123
+ }): Promise<CollectorResult<MemoryResult>> {
124
+ // Use free -m for memory in megabytes
125
+ const freeResult = await client.exec("free -m");
126
+ const parsed = this.parseFreeOutput(freeResult.stdout);
127
+
128
+ const result: MemoryResult = {
129
+ totalMb: parsed.totalMb,
130
+ usedMb: parsed.usedMb,
131
+ freeMb: parsed.freeMb,
132
+ usedPercent: parsed.usedPercent,
133
+ };
134
+
135
+ if (config.includeSwap && parsed.swapTotalMb > 0) {
136
+ result.swapTotalMb = parsed.swapTotalMb;
137
+ result.swapUsedMb = parsed.swapUsedMb;
138
+ }
139
+
140
+ return { result };
141
+ }
142
+
143
+ aggregateResult(
144
+ runs: HealthCheckRunForAggregation<MemoryResult>[]
145
+ ): MemoryAggregatedResult {
146
+ const usedPercents = runs
147
+ .map((r) => r.metadata?.usedPercent)
148
+ .filter((v): v is number => typeof v === "number");
149
+
150
+ const usedMbs = runs
151
+ .map((r) => r.metadata?.usedMb)
152
+ .filter((v): v is number => typeof v === "number");
153
+
154
+ return {
155
+ avgUsedPercent: usedPercents.length > 0 ? this.avg(usedPercents) : 0,
156
+ maxUsedPercent: usedPercents.length > 0 ? Math.max(...usedPercents) : 0,
157
+ avgUsedMb: usedMbs.length > 0 ? this.avg(usedMbs) : 0,
158
+ };
159
+ }
160
+
161
+ // ============================================================================
162
+ // PARSING HELPERS
163
+ // ============================================================================
164
+
165
+ private parseFreeOutput(output: string): {
166
+ totalMb: number;
167
+ usedMb: number;
168
+ freeMb: number;
169
+ usedPercent: number;
170
+ swapTotalMb: number;
171
+ swapUsedMb: number;
172
+ } {
173
+ // Format:
174
+ // total used free shared buff/cache available
175
+ // Mem: 15896 5234 1234 123 9428 10234
176
+ // Swap: 4096 512 3584
177
+
178
+ const lines = output.trim().split("\n");
179
+ const memLine = lines.find((l) => l.startsWith("Mem:"));
180
+ const swapLine = lines.find((l) => l.startsWith("Swap:"));
181
+
182
+ const memParts = memLine?.split(/\s+/).map(Number) ?? [];
183
+ const swapParts = swapLine?.split(/\s+/).map(Number) ?? [];
184
+
185
+ const totalMb = memParts[1] || 0;
186
+ const usedMb = memParts[2] || 0;
187
+ const freeMb = memParts[3] || 0;
188
+ const usedPercent =
189
+ totalMb > 0 ? Math.round((usedMb / totalMb) * 100 * 10) / 10 : 0;
190
+
191
+ return {
192
+ totalMb,
193
+ usedMb,
194
+ freeMb,
195
+ usedPercent,
196
+ swapTotalMb: swapParts[1] || 0,
197
+ swapUsedMb: swapParts[2] || 0,
198
+ };
199
+ }
200
+
201
+ private avg(nums: number[]): number {
202
+ return (
203
+ Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10
204
+ );
205
+ }
206
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
+ import { CpuCollector, MemoryCollector, DiskCollector } from "./collectors";
3
+ import { pluginMetadata } from "./plugin-metadata";
4
+
5
+ export default createBackendPlugin({
6
+ metadata: pluginMetadata,
7
+ register(env) {
8
+ env.registerInit({
9
+ deps: {
10
+ collectorRegistry: coreServices.collectorRegistry,
11
+ logger: coreServices.logger,
12
+ },
13
+ init: async ({ collectorRegistry, logger }) => {
14
+ logger.debug("🔌 Registering hardware collectors...");
15
+
16
+ // Register all hardware collectors
17
+ // Owner plugin metadata is auto-injected via scoped factory
18
+ collectorRegistry.register(new CpuCollector());
19
+ collectorRegistry.register(new MemoryCollector());
20
+ collectorRegistry.register(new DiskCollector());
21
+
22
+ logger.info("✅ Hardware collectors registered (CPU, Memory, Disk)");
23
+ },
24
+ });
25
+ },
26
+ });
@@ -0,0 +1,5 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata = definePluginMetadata({
4
+ pluginId: "collector-hardware",
5
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }