@checkstack/healthcheck-backend 0.11.0 → 0.12.1

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 CHANGED
@@ -1,5 +1,76 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d1a2796: Enforce stricter code quality standards and eliminate AI slop anti-patterns.
8
+
9
+ **New utility**
10
+
11
+ - `extractErrorMessage(error, fallback?)` in `@checkstack/common` for consistent error extraction
12
+
13
+ **ESLint rules**
14
+
15
+ - `react-hooks/rules-of-hooks` and `exhaustive-deps` for hook correctness
16
+ - `no-console` in frontend packages — forces `toast` over silent `console.error`
17
+ - `no-restricted-syntax` banning `instanceof Error` — forces `extractErrorMessage`
18
+ - Custom `no-eslint-disable-any` rule preventing `@typescript-eslint/no-explicit-any` circumvention
19
+
20
+ **Refactoring**
21
+
22
+ - Replace 141 `instanceof Error` boilerplate patterns across the codebase
23
+ - Replace swallowed `console.error` with user-visible `toast.error()` feedback
24
+ - Remove 15 redundant `as` type casts in IntegrationsPage and ProviderConnectionsPage
25
+ - Consolidate 3 identical callback handlers into `handleDialogClose`
26
+ - Fix conditional React hook call in `FormField.tsx`
27
+ - Fix unstable useMemo deps in `Dashboard.tsx`
28
+ - Replace `useEffect`→`setState` with derived `useMemo` in `RegisterPage.tsx`
29
+ - Rewrite `keystore.test.ts` with typed `DrizzleMockChain` (eliminating 7 `any` suppressions)
30
+ - Delete obvious comments in `encryption.ts` and Teams `provider.ts`
31
+
32
+ - Updated dependencies [d1a2796]
33
+ - Updated dependencies [3c34b07]
34
+ - @checkstack/common@0.6.5
35
+ - @checkstack/backend-api@0.11.1
36
+ - @checkstack/catalog-backend@0.2.23
37
+ - @checkstack/integration-backend@0.1.18
38
+ - @checkstack/catalog-common@1.3.1
39
+ - @checkstack/healthcheck-common@0.10.1
40
+ - @checkstack/command-backend@0.1.18
41
+ - @checkstack/incident-common@0.4.7
42
+ - @checkstack/maintenance-common@0.4.9
43
+ - @checkstack/signal-common@0.1.9
44
+ - @checkstack/queue-api@0.2.12
45
+
46
+ ## 0.12.0
47
+
48
+ ### Minor Changes
49
+
50
+ - 54a5f80: ### Health Check Editor Redesign — IDE-Style Experience
51
+
52
+ Replaces the modal-based health check editor with a full-page, IDE-style experience:
53
+
54
+ - **Strategy Picker Page**: New `/config/create` page with categorized strategy discovery, search filtering, and grouped card grid layout
55
+ - **IDE Editor Page**: New `/config/:configId/edit` page with a split-view layout — explorer tree on the left, editor panel on the right
56
+ - **Strategy Categories**: Introduces `StrategyCategory` enum with 16 categories (Networking, Database, Infrastructure, etc.) — all 13 strategy plugins now declare their category
57
+ - **New RPC Endpoint**: Added `getConfiguration` (singular by ID) for efficient single-resource fetching on the edit page
58
+ - **Explorer Tree**: Left-hand navigation with General, Check Items (collectors), and Access Control sections, with real-time validation indicators
59
+ - **Validation Status Bar**: Bottom bar showing aggregated validation issues with clickable navigation
60
+ - **Unsaved Changes Guard**: Browser `beforeunload` protection when the form is dirty
61
+ - **Responsive Design**: Split-view on desktop, stacked layout on mobile
62
+ - **Deleted**: Legacy `HealthCheckEditor.tsx` modal component
63
+
64
+ ### Patch Changes
65
+
66
+ - Updated dependencies [54a5f80]
67
+ - @checkstack/healthcheck-common@0.10.0
68
+ - @checkstack/backend-api@0.11.0
69
+ - @checkstack/catalog-backend@0.2.22
70
+ - @checkstack/command-backend@0.1.17
71
+ - @checkstack/integration-backend@0.1.17
72
+ - @checkstack/queue-api@0.2.11
73
+
3
74
  ## 0.11.0
4
75
 
5
76
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -13,16 +13,16 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/backend-api": "0.10.0",
17
- "@checkstack/catalog-backend": "0.2.20",
18
- "@checkstack/catalog-common": "1.2.11",
19
- "@checkstack/command-backend": "0.1.15",
16
+ "@checkstack/backend-api": "0.11.0",
17
+ "@checkstack/catalog-backend": "0.2.22",
18
+ "@checkstack/catalog-common": "1.3.0",
19
+ "@checkstack/command-backend": "0.1.17",
20
20
  "@checkstack/common": "0.6.4",
21
- "@checkstack/healthcheck-common": "0.8.4",
21
+ "@checkstack/healthcheck-common": "0.10.0",
22
22
  "@checkstack/incident-common": "0.4.6",
23
- "@checkstack/integration-backend": "0.1.15",
23
+ "@checkstack/integration-backend": "0.1.17",
24
24
  "@checkstack/maintenance-common": "0.4.8",
25
- "@checkstack/queue-api": "0.2.9",
25
+ "@checkstack/queue-api": "0.2.11",
26
26
  "@checkstack/signal-common": "0.1.8",
27
27
  "@hono/zod-validator": "^0.7.6",
28
28
  "drizzle-orm": "^0.45.0",
@@ -34,8 +34,8 @@
34
34
  "devDependencies": {
35
35
  "@checkstack/drizzle-helper": "0.0.4",
36
36
  "@checkstack/scripts": "0.1.2",
37
- "@checkstack/test-utils-backend": "0.1.15",
38
- "@checkstack/tsconfig": "0.0.4",
37
+ "@checkstack/test-utils-backend": "0.1.17",
38
+ "@checkstack/tsconfig": "0.0.5",
39
39
  "@types/bun": "^1.0.0",
40
40
  "@types/tdigest": "^0.1.5",
41
41
  "date-fns": "^4.1.0",
@@ -27,7 +27,7 @@ import {
27
27
  import { CatalogApi, catalogRoutes } from "@checkstack/catalog-common";
28
28
  import { MaintenanceApi } from "@checkstack/maintenance-common";
29
29
  import { IncidentApi } from "@checkstack/incident-common";
30
- import { resolveRoute, type InferClient } from "@checkstack/common";
30
+ import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
31
31
  import { HealthCheckService } from "./service";
32
32
  import { healthCheckHooks } from "./hooks";
33
33
  import { incrementHourlyAggregate } from "./realtime-aggregation";
@@ -400,7 +400,7 @@ async function executeHealthCheckJob(props: {
400
400
  };
401
401
  } catch (error) {
402
402
  const errorStr =
403
- error instanceof Error ? error.message : String(error);
403
+ extractErrorMessage(error);
404
404
  logger.debug(`Collector ${storageKey} failed: ${errorStr}`);
405
405
  return {
406
406
  storageKey,
@@ -465,7 +465,7 @@ async function executeHealthCheckJob(props: {
465
465
  } catch (error) {
466
466
  const latencyMs = Math.round(performance.now() - start);
467
467
  const caughtError =
468
- error instanceof Error ? error.message : String(error);
468
+ extractErrorMessage(error);
469
469
 
470
470
  // Use a specific error message if available, otherwise use the caught error
471
471
  const finalError = errorMessage || caughtError;
@@ -66,6 +66,7 @@ describe("HealthCheck Router", () => {
66
66
  id: "http",
67
67
  displayName: "HTTP",
68
68
  description: "Check HTTP",
69
+ category: "networking",
69
70
  config: {
70
71
  version: 1,
71
72
  schema: z.object({}),
@@ -97,6 +98,19 @@ describe("HealthCheck Router", () => {
97
98
  expect(Array.isArray(result.configurations)).toBe(true);
98
99
  });
99
100
 
101
+ it("getConfiguration returns undefined for non-existent config", async () => {
102
+ const context = createMockRpcContext({
103
+ user: mockUser,
104
+ });
105
+
106
+ const result = await call(
107
+ router.getConfiguration,
108
+ { id: "non-existent" },
109
+ { context },
110
+ );
111
+ expect(result).toBeUndefined();
112
+ });
113
+
100
114
  it("getCollectors returns collectors for strategy", async () => {
101
115
  const mockCollector = {
102
116
  qualifiedId: "collector-hardware.cpu",
package/src/router.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type CollectorRegistry,
9
9
  } from "@checkstack/backend-api";
10
10
  import { healthCheckContract } from "@checkstack/healthcheck-common";
11
+ import type { StrategyCategory } from "@checkstack/healthcheck-common";
11
12
  import { HealthCheckService } from "./service";
12
13
  import * as schema from "./schema";
13
14
  import { toJsonSchemaWithChartMeta } from "./schema-utils";
@@ -37,6 +38,7 @@ export const createHealthCheckRouter = (
37
38
  id: r.qualifiedId, // Return fully qualified ID
38
39
  displayName: r.strategy.displayName,
39
40
  description: r.strategy.description,
41
+ category: (r.strategy.category ?? "other") as StrategyCategory,
40
42
  configSchema: toJsonSchema(r.strategy.config.schema),
41
43
  resultSchema: r.strategy.result
42
44
  ? toJsonSchemaWithChartMeta(r.strategy.result.schema)
@@ -87,6 +89,10 @@ export const createHealthCheckRouter = (
87
89
  return { configurations: await service.getConfigurations() };
88
90
  }),
89
91
 
92
+ getConfiguration: os.getConfiguration.handler(async ({ input }) => {
93
+ return service.getConfiguration(input.id);
94
+ }),
95
+
90
96
  createConfiguration: os.createConfiguration.handler(async ({ input }) => {
91
97
  return service.createConfiguration(input);
92
98
  }),
@@ -224,10 +230,6 @@ export const createHealthCheckRouter = (
224
230
  return service.getSystemHealthOverview(input.systemId);
225
231
  },
226
232
  ),
227
-
228
- getAvailabilityStats: os.getAvailabilityStats.handler(async ({ input }) => {
229
- return service.getAvailabilityStats(input);
230
- }),
231
233
  });
232
234
  };
233
235
 
package/src/service.ts CHANGED
@@ -12,10 +12,9 @@ import {
12
12
  healthCheckRuns,
13
13
  healthCheckAggregates,
14
14
  VersionedStateThresholds,
15
- DEFAULT_RETENTION_CONFIG,
16
15
  } from "./schema";
17
16
  import * as schema from "./schema";
18
- import { eq, and, InferSelectModel, desc, gte, lte, lt } from "drizzle-orm";
17
+ import { eq, and, InferSelectModel, desc, gte, lte } from "drizzle-orm";
19
18
  import { ORPCError } from "@orpc/server";
20
19
  import { evaluateHealthStatus } from "./state-evaluator";
21
20
  import { stateThresholds } from "./state-thresholds-migrations";
@@ -928,122 +927,6 @@ export class HealthCheckService {
928
927
  * Calculate bucket start time for dynamic interval sizing.
929
928
  * Aligns buckets to the query start time.
930
929
  */
931
- /**
932
- * Get availability statistics for a health check over 31-day and 365-day periods.
933
- * Availability is calculated as (healthyCount / totalRunCount) * 100.
934
- *
935
- * With incremental real-time aggregation, hourly aggregates are always up-to-date
936
- * (updated immediately on every run), so we don't need to query raw runs.
937
- */
938
- async getAvailabilityStats(props: {
939
- systemId: string;
940
- configurationId: string;
941
- }): Promise<{
942
- availability31Days: number | null;
943
- availability365Days: number | null;
944
- totalRuns31Days: number;
945
- totalRuns365Days: number;
946
- }> {
947
- const { systemId, configurationId } = props;
948
- const now = new Date();
949
-
950
- // Get retention config to determine what data tiers are available
951
- const { retentionConfig } = await this.getRetentionConfig(
952
- systemId,
953
- configurationId,
954
- );
955
- const config = retentionConfig ?? DEFAULT_RETENTION_CONFIG;
956
-
957
- // Calculate cutoff dates
958
- const cutoff31Days = new Date(now.getTime() - 31 * 24 * 60 * 60 * 1000);
959
- const cutoff365Days = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
960
-
961
- // Cutoff for hourly aggregates based on retention config
962
- const hourlyCutoff = new Date(
963
- now.getTime() - config.hourlyRetentionDays * 24 * 60 * 60 * 1000,
964
- );
965
-
966
- // Query hourly aggregates for the period they cover (up to hourlyRetentionDays)
967
- // These are always up-to-date due to incremental real-time aggregation
968
- const hourlyAggregates = await this.db
969
- .select({
970
- bucketStart: healthCheckAggregates.bucketStart,
971
- runCount: healthCheckAggregates.runCount,
972
- healthyCount: healthCheckAggregates.healthyCount,
973
- })
974
- .from(healthCheckAggregates)
975
- .where(
976
- and(
977
- eq(healthCheckAggregates.systemId, systemId),
978
- eq(healthCheckAggregates.configurationId, configurationId),
979
- eq(healthCheckAggregates.bucketSize, "hourly"),
980
- gte(healthCheckAggregates.bucketStart, hourlyCutoff),
981
- ),
982
- );
983
-
984
- // Query daily aggregates for data beyond hourly retention
985
- const dailyAggregates = await this.db
986
- .select({
987
- bucketStart: healthCheckAggregates.bucketStart,
988
- runCount: healthCheckAggregates.runCount,
989
- healthyCount: healthCheckAggregates.healthyCount,
990
- })
991
- .from(healthCheckAggregates)
992
- .where(
993
- and(
994
- eq(healthCheckAggregates.systemId, systemId),
995
- eq(healthCheckAggregates.configurationId, configurationId),
996
- eq(healthCheckAggregates.bucketSize, "daily"),
997
- gte(healthCheckAggregates.bucketStart, cutoff365Days),
998
- lt(healthCheckAggregates.bucketStart, hourlyCutoff),
999
- ),
1000
- );
1001
-
1002
- // Aggregate counts
1003
- let totalRuns31Days = 0;
1004
- let healthyRuns31Days = 0;
1005
- let totalRuns365Days = 0;
1006
- let healthyRuns365Days = 0;
1007
-
1008
- // Process hourly aggregates (fresh data within hourlyRetentionDays)
1009
- for (const agg of hourlyAggregates) {
1010
- totalRuns365Days += agg.runCount;
1011
- healthyRuns365Days += agg.healthyCount;
1012
-
1013
- if (agg.bucketStart >= cutoff31Days) {
1014
- totalRuns31Days += agg.runCount;
1015
- healthyRuns31Days += agg.healthyCount;
1016
- }
1017
- }
1018
-
1019
- // Process daily aggregates (older data beyond hourly retention)
1020
- for (const agg of dailyAggregates) {
1021
- totalRuns365Days += agg.runCount;
1022
- healthyRuns365Days += agg.healthyCount;
1023
-
1024
- if (agg.bucketStart >= cutoff31Days) {
1025
- totalRuns31Days += agg.runCount;
1026
- healthyRuns31Days += agg.healthyCount;
1027
- }
1028
- }
1029
-
1030
- // Calculate availability percentages
1031
- const availability31Days =
1032
- // eslint-disable-next-line unicorn/no-null -- RPC contract uses nullable()
1033
- totalRuns31Days > 0 ? (healthyRuns31Days / totalRuns31Days) * 100 : null;
1034
- const availability365Days =
1035
- totalRuns365Days > 0
1036
- ? (healthyRuns365Days / totalRuns365Days) * 100
1037
- : // eslint-disable-next-line unicorn/no-null -- RPC contract uses nullable()
1038
- null;
1039
-
1040
- return {
1041
- availability31Days,
1042
- availability365Days,
1043
- totalRuns31Days,
1044
- totalRuns365Days,
1045
- };
1046
- }
1047
930
 
1048
931
  private getBucketStartDynamic(
1049
932
  timestamp: Date,
@@ -1,236 +0,0 @@
1
- import { describe, it, expect, mock, beforeEach } from "bun:test";
2
- import { HealthCheckService } from "./service";
3
- import { subDays, subHours } from "date-fns";
4
-
5
- describe("HealthCheckService.getAvailabilityStats", () => {
6
- // Mock database
7
- let mockDb: ReturnType<typeof createMockDb>;
8
- let service: HealthCheckService;
9
-
10
- // Store mock data for different queries
11
- let mockHourlyAggregates: Array<{
12
- bucketStart: Date;
13
- runCount: number;
14
- healthyCount: number;
15
- }> = [];
16
- let mockDailyAggregates: Array<{
17
- bucketStart: Date;
18
- runCount: number;
19
- healthyCount: number;
20
- }> = [];
21
- let mockRetentionConfig: { retentionConfig: unknown } | undefined = undefined;
22
-
23
- function createMockDb() {
24
- // Select call order for getAvailabilityStats:
25
- // 1. getRetentionConfig (from systemHealthChecks) - uses .then() pattern
26
- // 2. hourlyAggregates
27
- // 3. dailyAggregates
28
- let selectCallCount = 0;
29
-
30
- const createSelectChain = () => {
31
- const currentCall = selectCallCount++;
32
-
33
- return {
34
- from: mock(() => ({
35
- where: mock(() => {
36
- // Call 0: retentionConfig - uses .then() pattern
37
- if (currentCall === 0) {
38
- const result = mockRetentionConfig ? [mockRetentionConfig] : [];
39
- // Return a promise-like object with .then()
40
- return Promise.resolve(result);
41
- }
42
- // Call 1: hourly aggregates
43
- if (currentCall === 1) return Promise.resolve(mockHourlyAggregates);
44
- // Call 2: daily aggregates
45
- return Promise.resolve(mockDailyAggregates);
46
- }),
47
- })),
48
- };
49
- };
50
-
51
- return {
52
- select: mock(createSelectChain),
53
- };
54
- }
55
-
56
- beforeEach(() => {
57
- // Reset mock data
58
- mockHourlyAggregates = [];
59
- mockDailyAggregates = [];
60
- mockRetentionConfig = undefined;
61
- mockDb = createMockDb();
62
- service = new HealthCheckService(mockDb as never, {} as never, {} as never);
63
- });
64
-
65
- describe("with no data", () => {
66
- it("returns null availability when no aggregates exist", async () => {
67
- const result = await service.getAvailabilityStats({
68
- systemId: "sys-1",
69
- configurationId: "config-1",
70
- });
71
-
72
- expect(result.availability31Days).toBeNull();
73
- expect(result.availability365Days).toBeNull();
74
- expect(result.totalRuns31Days).toBe(0);
75
- expect(result.totalRuns365Days).toBe(0);
76
- });
77
- });
78
-
79
- describe("with hourly aggregates (real-time incremental)", () => {
80
- it("calculates 100% availability when all runs are healthy", async () => {
81
- mockHourlyAggregates = [
82
- {
83
- bucketStart: subHours(new Date(), 2),
84
- runCount: 100,
85
- healthyCount: 100,
86
- },
87
- {
88
- bucketStart: subHours(new Date(), 5),
89
- runCount: 100,
90
- healthyCount: 100,
91
- },
92
- ];
93
-
94
- const result = await service.getAvailabilityStats({
95
- systemId: "sys-1",
96
- configurationId: "config-1",
97
- });
98
-
99
- expect(result.availability31Days).toBe(100);
100
- expect(result.availability365Days).toBe(100);
101
- expect(result.totalRuns31Days).toBe(200);
102
- expect(result.totalRuns365Days).toBe(200);
103
- });
104
-
105
- it("calculates correct availability with mixed results", async () => {
106
- mockHourlyAggregates = [
107
- {
108
- bucketStart: subHours(new Date(), 2),
109
- runCount: 100,
110
- healthyCount: 90,
111
- },
112
- {
113
- bucketStart: subHours(new Date(), 5),
114
- runCount: 100,
115
- healthyCount: 80,
116
- },
117
- ];
118
-
119
- const result = await service.getAvailabilityStats({
120
- systemId: "sys-1",
121
- configurationId: "config-1",
122
- });
123
-
124
- // 170 healthy / 200 total = 85%
125
- expect(result.availability31Days).toBe(85);
126
- expect(result.availability365Days).toBe(85);
127
- });
128
-
129
- it("includes current hour data since aggregates are updated incrementally", async () => {
130
- const currentHourStart = new Date();
131
- currentHourStart.setMinutes(0, 0, 0);
132
-
133
- mockHourlyAggregates = [
134
- { bucketStart: currentHourStart, runCount: 10, healthyCount: 9 },
135
- ];
136
-
137
- const result = await service.getAvailabilityStats({
138
- systemId: "sys-1",
139
- configurationId: "config-1",
140
- });
141
-
142
- expect(result.totalRuns31Days).toBe(10);
143
- expect(result.availability31Days).toBe(90);
144
- });
145
- });
146
-
147
- describe("with combined hourly and daily aggregates", () => {
148
- it("combines hourly and daily data correctly", async () => {
149
- mockHourlyAggregates = [
150
- {
151
- bucketStart: subHours(new Date(), 2),
152
- runCount: 100,
153
- healthyCount: 99,
154
- },
155
- ];
156
-
157
- mockDailyAggregates = [
158
- {
159
- bucketStart: subDays(new Date(), 60),
160
- runCount: 100,
161
- healthyCount: 50,
162
- },
163
- ];
164
-
165
- const result = await service.getAvailabilityStats({
166
- systemId: "sys-1",
167
- configurationId: "config-1",
168
- });
169
-
170
- // 31 days: only hourly (99/100) = 99%
171
- expect(result.availability31Days).toBe(99);
172
- expect(result.totalRuns31Days).toBe(100);
173
-
174
- // 365 days: (99+50)/200 = 74.5%
175
- expect(result.availability365Days).toBe(74.5);
176
- expect(result.totalRuns365Days).toBe(200);
177
- });
178
- });
179
-
180
- describe("99.9% availability calculation", () => {
181
- it("calculates precise availability for SLA tracking", async () => {
182
- mockHourlyAggregates = [
183
- {
184
- bucketStart: subHours(new Date(), 5),
185
- runCount: 1000,
186
- healthyCount: 999,
187
- },
188
- ];
189
-
190
- const result = await service.getAvailabilityStats({
191
- systemId: "sys-1",
192
- configurationId: "config-1",
193
- });
194
-
195
- expect(result.availability31Days).toBe(99.9);
196
- expect(result.availability365Days).toBe(99.9);
197
- });
198
-
199
- it("calculates very high availability correctly", async () => {
200
- mockHourlyAggregates = [
201
- {
202
- bucketStart: subHours(new Date(), 5),
203
- runCount: 10_000,
204
- healthyCount: 9999,
205
- },
206
- ];
207
-
208
- const result = await service.getAvailabilityStats({
209
- systemId: "sys-1",
210
- configurationId: "config-1",
211
- });
212
-
213
- expect(result.availability31Days).toBe(99.99);
214
- });
215
- });
216
-
217
- describe("real-time incremental aggregation behavior", () => {
218
- it("uses hourly aggregates directly without raw run queries", async () => {
219
- mockHourlyAggregates = [
220
- {
221
- bucketStart: subHours(new Date(), 1),
222
- runCount: 50,
223
- healthyCount: 48,
224
- },
225
- ];
226
-
227
- const result = await service.getAvailabilityStats({
228
- systemId: "sys-1",
229
- configurationId: "config-1",
230
- });
231
-
232
- expect(result.availability31Days).toBe(96);
233
- expect(result.totalRuns31Days).toBe(50);
234
- });
235
- });
236
- });