@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 +71 -0
- package/package.json +10 -10
- package/src/queue-executor.ts +3 -3
- package/src/router.test.ts +14 -0
- package/src/router.ts +6 -4
- package/src/service.ts +1 -118
- package/src/availability.test.ts +0 -236
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.
|
|
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.
|
|
17
|
-
"@checkstack/catalog-backend": "0.2.
|
|
18
|
-
"@checkstack/catalog-common": "1.
|
|
19
|
-
"@checkstack/command-backend": "0.1.
|
|
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.
|
|
21
|
+
"@checkstack/healthcheck-common": "0.10.0",
|
|
22
22
|
"@checkstack/incident-common": "0.4.6",
|
|
23
|
-
"@checkstack/integration-backend": "0.1.
|
|
23
|
+
"@checkstack/integration-backend": "0.1.17",
|
|
24
24
|
"@checkstack/maintenance-common": "0.4.8",
|
|
25
|
-
"@checkstack/queue-api": "0.2.
|
|
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.
|
|
38
|
-
"@checkstack/tsconfig": "0.0.
|
|
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",
|
package/src/queue-executor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/router.test.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/availability.test.ts
DELETED
|
@@ -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
|
-
});
|