@checkstack/healthcheck-jenkins-backend 0.2.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.
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { QueueInfoCollector } from "./queue-info";
3
+ import type {
4
+ JenkinsTransportClient,
5
+ JenkinsResponse,
6
+ } from "../transport-client";
7
+
8
+ describe("QueueInfoCollector", () => {
9
+ const collector = new QueueInfoCollector();
10
+
11
+ const createMockClient = (
12
+ response: JenkinsResponse
13
+ ): JenkinsTransportClient => ({
14
+ exec: async () => response,
15
+ });
16
+
17
+ it("should collect queue info successfully", async () => {
18
+ const now = Date.now();
19
+ const mockClient = createMockClient({
20
+ statusCode: 200,
21
+ data: {
22
+ items: [
23
+ {
24
+ id: 1,
25
+ blocked: false,
26
+ buildable: true,
27
+ stuck: false,
28
+ inQueueSince: now - 5000,
29
+ },
30
+ {
31
+ id: 2,
32
+ blocked: true,
33
+ buildable: false,
34
+ stuck: false,
35
+ inQueueSince: now - 10000,
36
+ },
37
+ {
38
+ id: 3,
39
+ blocked: false,
40
+ buildable: true,
41
+ stuck: true,
42
+ inQueueSince: now - 30000,
43
+ },
44
+ ],
45
+ },
46
+ });
47
+
48
+ const result = await collector.execute({
49
+ config: {},
50
+ client: mockClient,
51
+ pluginId: "healthcheck-jenkins",
52
+ });
53
+
54
+ expect(result.result.queueLength).toBe(3);
55
+ expect(result.result.blockedCount).toBe(1);
56
+ expect(result.result.buildableCount).toBe(2);
57
+ expect(result.result.stuckCount).toBe(1);
58
+ expect(result.result.oldestWaitingMs).toBeGreaterThanOrEqual(30000);
59
+ // Error should be set because stuck > 0
60
+ expect(result.error).toContain("stuck");
61
+ });
62
+
63
+ it("should report no error for empty queue", async () => {
64
+ const mockClient = createMockClient({
65
+ statusCode: 200,
66
+ data: { items: [] },
67
+ });
68
+
69
+ const result = await collector.execute({
70
+ config: {},
71
+ client: mockClient,
72
+ pluginId: "healthcheck-jenkins",
73
+ });
74
+
75
+ expect(result.error).toBeUndefined();
76
+ expect(result.result.queueLength).toBe(0);
77
+ });
78
+
79
+ it("should aggregate correctly", () => {
80
+ const runs: Parameters<typeof collector.aggregateResult>[0] = [
81
+ {
82
+ status: "healthy" as const,
83
+ latencyMs: 100,
84
+ metadata: {
85
+ queueLength: 5,
86
+ blockedCount: 1,
87
+ buildableCount: 4,
88
+ stuckCount: 0,
89
+ oldestWaitingMs: 15000,
90
+ avgWaitingMs: 10000,
91
+ },
92
+ },
93
+ {
94
+ status: "healthy" as const,
95
+ latencyMs: 100,
96
+ metadata: {
97
+ queueLength: 3,
98
+ blockedCount: 0,
99
+ buildableCount: 3,
100
+ stuckCount: 0,
101
+ oldestWaitingMs: 25000,
102
+ avgWaitingMs: 20000,
103
+ },
104
+ },
105
+ ];
106
+
107
+ const aggregated = collector.aggregateResult(runs);
108
+
109
+ expect(aggregated.avgQueueLength).toBe(4);
110
+ expect(aggregated.maxQueueLength).toBe(5);
111
+ expect(aggregated.avgWaitTime).toBe(15000);
112
+ });
113
+ });
@@ -0,0 +1,215 @@
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 { pluginMetadata } from "../plugin-metadata";
10
+ import type { JenkinsTransportClient } from "../transport-client";
11
+
12
+ // ============================================================================
13
+ // CONFIGURATION SCHEMA
14
+ // ============================================================================
15
+
16
+ const queueInfoConfigSchema = z.object({});
17
+
18
+ export type QueueInfoConfig = z.infer<typeof queueInfoConfigSchema>;
19
+
20
+ // ============================================================================
21
+ // RESULT SCHEMAS
22
+ // ============================================================================
23
+
24
+ const queueInfoResultSchema = z.object({
25
+ queueLength: healthResultNumber({
26
+ "x-chart-type": "counter",
27
+ "x-chart-label": "Queue Length",
28
+ }),
29
+ blockedCount: healthResultNumber({
30
+ "x-chart-type": "counter",
31
+ "x-chart-label": "Blocked Items",
32
+ }),
33
+ buildableCount: healthResultNumber({
34
+ "x-chart-type": "counter",
35
+ "x-chart-label": "Buildable Items",
36
+ }),
37
+ stuckCount: healthResultNumber({
38
+ "x-chart-type": "counter",
39
+ "x-chart-label": "Stuck Items",
40
+ }),
41
+ oldestWaitingMs: healthResultNumber({
42
+ "x-chart-type": "line",
43
+ "x-chart-label": "Oldest Wait Time",
44
+ "x-chart-unit": "ms",
45
+ }),
46
+ avgWaitingMs: healthResultNumber({
47
+ "x-chart-type": "line",
48
+ "x-chart-label": "Avg Wait Time",
49
+ "x-chart-unit": "ms",
50
+ }),
51
+ });
52
+
53
+ export type QueueInfoResult = z.infer<typeof queueInfoResultSchema>;
54
+
55
+ const queueInfoAggregatedSchema = z.object({
56
+ avgQueueLength: healthResultNumber({
57
+ "x-chart-type": "line",
58
+ "x-chart-label": "Avg Queue Length",
59
+ }),
60
+ maxQueueLength: healthResultNumber({
61
+ "x-chart-type": "line",
62
+ "x-chart-label": "Max Queue Length",
63
+ }),
64
+ avgWaitTime: healthResultNumber({
65
+ "x-chart-type": "line",
66
+ "x-chart-label": "Avg Wait Time",
67
+ "x-chart-unit": "ms",
68
+ }),
69
+ });
70
+
71
+ export type QueueInfoAggregatedResult = z.infer<
72
+ typeof queueInfoAggregatedSchema
73
+ >;
74
+
75
+ // ============================================================================
76
+ // QUEUE INFO COLLECTOR
77
+ // ============================================================================
78
+
79
+ /**
80
+ * Collector for Jenkins build queue.
81
+ * Monitors queue length and wait times.
82
+ */
83
+ export class QueueInfoCollector
84
+ implements
85
+ CollectorStrategy<
86
+ JenkinsTransportClient,
87
+ QueueInfoConfig,
88
+ QueueInfoResult,
89
+ QueueInfoAggregatedResult
90
+ >
91
+ {
92
+ id = "queue-info";
93
+ displayName = "Queue Info";
94
+ description = "Monitor Jenkins build queue length and wait times";
95
+
96
+ supportedPlugins = [pluginMetadata];
97
+
98
+ config = new Versioned({ version: 1, schema: queueInfoConfigSchema });
99
+ result = new Versioned({ version: 1, schema: queueInfoResultSchema });
100
+ aggregatedResult = new Versioned({
101
+ version: 1,
102
+ schema: queueInfoAggregatedSchema,
103
+ });
104
+
105
+ async execute({
106
+ client,
107
+ }: {
108
+ config: QueueInfoConfig;
109
+ client: JenkinsTransportClient;
110
+ pluginId: string;
111
+ }): Promise<CollectorResult<QueueInfoResult>> {
112
+ const response = await client.exec({
113
+ path: "/queue/api/json",
114
+ query: {
115
+ tree: "items[id,why,stuck,blocked,buildable,inQueueSince]",
116
+ },
117
+ });
118
+
119
+ if (response.error) {
120
+ return {
121
+ result: {
122
+ queueLength: 0,
123
+ blockedCount: 0,
124
+ buildableCount: 0,
125
+ stuckCount: 0,
126
+ oldestWaitingMs: 0,
127
+ avgWaitingMs: 0,
128
+ },
129
+ error: response.error,
130
+ };
131
+ }
132
+
133
+ const data = response.data as {
134
+ items?: Array<{
135
+ id?: number;
136
+ why?: string;
137
+ stuck?: boolean;
138
+ blocked?: boolean;
139
+ buildable?: boolean;
140
+ inQueueSince?: number;
141
+ }>;
142
+ };
143
+
144
+ const items = data.items || [];
145
+ const now = Date.now();
146
+
147
+ let blockedCount = 0;
148
+ let buildableCount = 0;
149
+ let stuckCount = 0;
150
+ const waitTimes: number[] = [];
151
+
152
+ for (const item of items) {
153
+ if (item.blocked) blockedCount++;
154
+ if (item.buildable) buildableCount++;
155
+ if (item.stuck) stuckCount++;
156
+
157
+ if (item.inQueueSince) {
158
+ waitTimes.push(now - item.inQueueSince);
159
+ }
160
+ }
161
+
162
+ const oldestWaitingMs = waitTimes.length > 0 ? Math.max(...waitTimes) : 0;
163
+ const avgWaitingMs =
164
+ waitTimes.length > 0
165
+ ? Math.round(waitTimes.reduce((a, b) => a + b, 0) / waitTimes.length)
166
+ : 0;
167
+
168
+ const result: QueueInfoResult = {
169
+ queueLength: items.length,
170
+ blockedCount,
171
+ buildableCount,
172
+ stuckCount,
173
+ oldestWaitingMs,
174
+ avgWaitingMs,
175
+ };
176
+
177
+ // Warn if queue is backing up
178
+ const hasIssue = stuckCount > 0 || items.length > 10;
179
+
180
+ return {
181
+ result,
182
+ error: hasIssue
183
+ ? `Queue has ${items.length} items${
184
+ stuckCount > 0 ? `, ${stuckCount} stuck` : ""
185
+ }`
186
+ : undefined,
187
+ };
188
+ }
189
+
190
+ aggregateResult(
191
+ runs: HealthCheckRunForAggregation<QueueInfoResult>[]
192
+ ): QueueInfoAggregatedResult {
193
+ const queueLengths = runs
194
+ .map((r) => r.metadata?.queueLength)
195
+ .filter((v): v is number => typeof v === "number");
196
+
197
+ const waitTimes = runs
198
+ .map((r) => r.metadata?.avgWaitingMs)
199
+ .filter((v): v is number => typeof v === "number");
200
+
201
+ return {
202
+ avgQueueLength:
203
+ queueLengths.length > 0
204
+ ? Math.round(
205
+ queueLengths.reduce((a, b) => a + b, 0) / queueLengths.length
206
+ )
207
+ : 0,
208
+ maxQueueLength: queueLengths.length > 0 ? Math.max(...queueLengths) : 0,
209
+ avgWaitTime:
210
+ waitTimes.length > 0
211
+ ? Math.round(waitTimes.reduce((a, b) => a + b, 0) / waitTimes.length)
212
+ : 0,
213
+ };
214
+ }
215
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { ServerInfoCollector } from "./server-info";
3
+ import type {
4
+ JenkinsTransportClient,
5
+ JenkinsResponse,
6
+ } from "../transport-client";
7
+
8
+ describe("ServerInfoCollector", () => {
9
+ const collector = new ServerInfoCollector();
10
+
11
+ const createMockClient = (
12
+ response: JenkinsResponse
13
+ ): JenkinsTransportClient => ({
14
+ exec: async () => response,
15
+ });
16
+
17
+ it("should collect server info successfully", async () => {
18
+ const mockClient = createMockClient({
19
+ statusCode: 200,
20
+ jenkinsVersion: "2.426.1",
21
+ data: {
22
+ mode: "NORMAL",
23
+ numExecutors: 4,
24
+ usableWorkers: 3,
25
+ jobs: [{ name: "job1" }, { name: "job2" }, { name: "job3" }],
26
+ },
27
+ });
28
+
29
+ const result = await collector.execute({
30
+ config: {},
31
+ client: mockClient,
32
+ pluginId: "healthcheck-jenkins",
33
+ });
34
+
35
+ expect(result.error).toBeUndefined();
36
+ expect(result.result.jenkinsVersion).toBe("2.426.1");
37
+ expect(result.result.mode).toBe("NORMAL");
38
+ expect(result.result.numExecutors).toBe(4);
39
+ expect(result.result.usableWorkers).toBe(3);
40
+ expect(result.result.totalJobs).toBe(3);
41
+ });
42
+
43
+ it("should handle API error", async () => {
44
+ const mockClient = createMockClient({
45
+ statusCode: 401,
46
+ data: null,
47
+ error: "HTTP 401: Unauthorized",
48
+ });
49
+
50
+ const result = await collector.execute({
51
+ config: {},
52
+ client: mockClient,
53
+ pluginId: "healthcheck-jenkins",
54
+ });
55
+
56
+ expect(result.error).toBe("HTTP 401: Unauthorized");
57
+ });
58
+
59
+ it("should aggregate results correctly", () => {
60
+ const runs: Parameters<typeof collector.aggregateResult>[0] = [
61
+ {
62
+ status: "healthy" as const,
63
+ latencyMs: 100,
64
+ metadata: {
65
+ jenkinsVersion: "2.426.1",
66
+ mode: "NORMAL",
67
+ numExecutors: 4,
68
+ usableWorkers: 3,
69
+ totalJobs: 10,
70
+ },
71
+ },
72
+ {
73
+ status: "healthy" as const,
74
+ latencyMs: 100,
75
+ metadata: {
76
+ jenkinsVersion: "2.426.1",
77
+ mode: "NORMAL",
78
+ numExecutors: 6,
79
+ usableWorkers: 5,
80
+ totalJobs: 12,
81
+ },
82
+ },
83
+ ];
84
+
85
+ const aggregated = collector.aggregateResult(runs);
86
+
87
+ expect(aggregated.avgExecutors).toBe(5);
88
+ expect(aggregated.avgTotalJobs).toBe(11);
89
+ });
90
+ });
@@ -0,0 +1,169 @@
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 { pluginMetadata } from "../plugin-metadata";
13
+ import type { JenkinsTransportClient } from "../transport-client";
14
+
15
+ // ============================================================================
16
+ // CONFIGURATION SCHEMA
17
+ // ============================================================================
18
+
19
+ const serverInfoConfigSchema = z.object({});
20
+
21
+ export type ServerInfoConfig = z.infer<typeof serverInfoConfigSchema>;
22
+
23
+ // ============================================================================
24
+ // RESULT SCHEMAS
25
+ // ============================================================================
26
+
27
+ const serverInfoResultSchema = z.object({
28
+ jenkinsVersion: healthResultString({
29
+ "x-chart-type": "text",
30
+ "x-chart-label": "Jenkins Version",
31
+ }),
32
+ mode: healthResultString({
33
+ "x-chart-type": "text",
34
+ "x-chart-label": "Server Mode",
35
+ }),
36
+ numExecutors: healthResultNumber({
37
+ "x-chart-type": "counter",
38
+ "x-chart-label": "Executors",
39
+ }),
40
+ usableWorkers: healthResultNumber({
41
+ "x-chart-type": "counter",
42
+ "x-chart-label": "Usable Workers",
43
+ }),
44
+ totalJobs: healthResultNumber({
45
+ "x-chart-type": "counter",
46
+ "x-chart-label": "Total Jobs",
47
+ }),
48
+ uptime: healthResultNumber({
49
+ "x-chart-type": "line",
50
+ "x-chart-label": "Uptime",
51
+ "x-chart-unit": "hours",
52
+ }).optional(),
53
+ });
54
+
55
+ export type ServerInfoResult = z.infer<typeof serverInfoResultSchema>;
56
+
57
+ const serverInfoAggregatedSchema = z.object({
58
+ avgExecutors: healthResultNumber({
59
+ "x-chart-type": "line",
60
+ "x-chart-label": "Avg Executors",
61
+ }),
62
+ avgTotalJobs: healthResultNumber({
63
+ "x-chart-type": "line",
64
+ "x-chart-label": "Avg Jobs",
65
+ }),
66
+ });
67
+
68
+ export type ServerInfoAggregatedResult = z.infer<
69
+ typeof serverInfoAggregatedSchema
70
+ >;
71
+
72
+ // ============================================================================
73
+ // SERVER INFO COLLECTOR
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Built-in collector for Jenkins server information.
78
+ * Fetches basic server health metrics via /api/json.
79
+ */
80
+ export class ServerInfoCollector
81
+ implements
82
+ CollectorStrategy<
83
+ JenkinsTransportClient,
84
+ ServerInfoConfig,
85
+ ServerInfoResult,
86
+ ServerInfoAggregatedResult
87
+ >
88
+ {
89
+ id = "server-info";
90
+ displayName = "Server Info";
91
+ description = "Collects Jenkins server information and health metrics";
92
+
93
+ supportedPlugins = [pluginMetadata];
94
+
95
+ config = new Versioned({ version: 1, schema: serverInfoConfigSchema });
96
+ result = new Versioned({ version: 1, schema: serverInfoResultSchema });
97
+ aggregatedResult = new Versioned({
98
+ version: 1,
99
+ schema: serverInfoAggregatedSchema,
100
+ });
101
+
102
+ async execute({
103
+ client,
104
+ }: {
105
+ config: ServerInfoConfig;
106
+ client: JenkinsTransportClient;
107
+ pluginId: string;
108
+ }): Promise<CollectorResult<ServerInfoResult>> {
109
+ const response = await client.exec({
110
+ path: "/api/json",
111
+ query: {
112
+ tree: "mode,numExecutors,usableWorkers,jobs[name]",
113
+ },
114
+ });
115
+
116
+ if (response.error) {
117
+ return {
118
+ result: {
119
+ jenkinsVersion: "",
120
+ mode: "",
121
+ numExecutors: 0,
122
+ usableWorkers: 0,
123
+ totalJobs: 0,
124
+ },
125
+ error: response.error,
126
+ };
127
+ }
128
+
129
+ const data = response.data as {
130
+ mode?: string;
131
+ numExecutors?: number;
132
+ usableWorkers?: number;
133
+ jobs?: Array<{ name: string }>;
134
+ };
135
+
136
+ return {
137
+ result: {
138
+ jenkinsVersion: response.jenkinsVersion || "unknown",
139
+ mode: data.mode || "NORMAL",
140
+ numExecutors: data.numExecutors || 0,
141
+ usableWorkers: data.usableWorkers || 0,
142
+ totalJobs: data.jobs?.length || 0,
143
+ },
144
+ };
145
+ }
146
+
147
+ aggregateResult(
148
+ runs: HealthCheckRunForAggregation<ServerInfoResult>[]
149
+ ): ServerInfoAggregatedResult {
150
+ const executors = runs
151
+ .map((r) => r.metadata?.numExecutors)
152
+ .filter((v): v is number => typeof v === "number");
153
+
154
+ const jobs = runs
155
+ .map((r) => r.metadata?.totalJobs)
156
+ .filter((v): v is number => typeof v === "number");
157
+
158
+ return {
159
+ avgExecutors:
160
+ executors.length > 0
161
+ ? Math.round(executors.reduce((a, b) => a + b, 0) / executors.length)
162
+ : 0,
163
+ avgTotalJobs:
164
+ jobs.length > 0
165
+ ? Math.round(jobs.reduce((a, b) => a + b, 0) / jobs.length)
166
+ : 0,
167
+ };
168
+ }
169
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
+ import { JenkinsHealthCheckStrategy } from "./strategy";
3
+ import { pluginMetadata } from "./plugin-metadata";
4
+ import {
5
+ ServerInfoCollector,
6
+ JobStatusCollector,
7
+ BuildHistoryCollector,
8
+ QueueInfoCollector,
9
+ NodeHealthCollector,
10
+ } from "./collectors";
11
+
12
+ export default createBackendPlugin({
13
+ metadata: pluginMetadata,
14
+ register(env) {
15
+ env.registerInit({
16
+ deps: {
17
+ healthCheckRegistry: coreServices.healthCheckRegistry,
18
+ collectorRegistry: coreServices.collectorRegistry,
19
+ logger: coreServices.logger,
20
+ },
21
+ init: async ({ healthCheckRegistry, collectorRegistry, logger }) => {
22
+ logger.debug("🔌 Registering Jenkins Health Check Strategy...");
23
+
24
+ // Register the transport strategy
25
+ const strategy = new JenkinsHealthCheckStrategy();
26
+ healthCheckRegistry.register(strategy);
27
+
28
+ // Register all collectors
29
+ collectorRegistry.register(new ServerInfoCollector());
30
+ collectorRegistry.register(new JobStatusCollector());
31
+ collectorRegistry.register(new BuildHistoryCollector());
32
+ collectorRegistry.register(new QueueInfoCollector());
33
+ collectorRegistry.register(new NodeHealthCollector());
34
+
35
+ logger.info(
36
+ "✅ Jenkins health check registered (strategy + 5 collectors)"
37
+ );
38
+ },
39
+ });
40
+ },
41
+ });
42
+
43
+ export { pluginMetadata } from "./plugin-metadata";
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the Jenkins Health Check backend.
5
+ * This is the single source of truth for the plugin ID.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "healthcheck-jenkins",
9
+ });