@checkstack/healthcheck-backend 1.17.0 → 1.19.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 +559 -0
- package/package.json +32 -29
- package/src/adaptive-timeout.test.ts +91 -0
- package/src/adaptive-timeout.ts +75 -0
- package/src/ai/system-signals-contributor.test.ts +2 -0
- package/src/automations.test.ts +47 -0
- package/src/automations.ts +19 -3
- package/src/health-notification-content.test.ts +89 -0
- package/src/health-notification-content.ts +138 -0
- package/src/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +58 -6
- package/src/migration-chain-contract.test.ts +7 -1
- package/src/notification-policy.test.ts +19 -0
- package/src/notification-policy.ts +26 -0
- package/src/queue-executor.test.ts +391 -338
- package/src/queue-executor.ts +426 -362
- package/src/realtime-aggregation.ts +9 -2
- package/src/rollup-consumer.test.ts +191 -0
- package/src/rollup-consumer.ts +160 -0
- package/src/router.ts +46 -13
- package/src/schedule-jitter.test.ts +69 -0
- package/src/schedule-jitter.ts +50 -0
- package/src/schedule-reconciler.it.test.ts +453 -0
- package/src/schedule-reconciler.test.ts +418 -0
- package/src/schedule-reconciler.ts +304 -0
- package/src/service-batching.test.ts +106 -0
- package/src/service-bulk-counts.it.test.ts +144 -0
- package/src/service-bulk-run-stats.it.test.ts +197 -0
- package/src/service-ordering.test.ts +10 -2
- package/src/service-paused-filter.test.ts +27 -7
- package/src/service-rollup-worst-wins.test.ts +221 -124
- package/src/service.ts +557 -266
- package/src/slow-check-admission.test.ts +184 -0
- package/src/slow-check-admission.ts +101 -0
- package/src/slow-check-classifier.test.ts +155 -0
- package/src/slow-check-classifier.ts +137 -0
- package/src/slow-check-config.ts +102 -0
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +303 -0
- package/src/status-page/widgets.ts +155 -39
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
package/src/service.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type HealthcheckSignalStatuses,
|
|
14
14
|
type RunStats,
|
|
15
15
|
stripEphemeralFields,
|
|
16
|
+
selectEffectiveEnvKeys,
|
|
16
17
|
} from "@checkstack/healthcheck-common";
|
|
17
18
|
import { evaluateCollectorAssertionOutcomes } from "./collector-assertions";
|
|
18
19
|
import { summarizeRuns, type StatRun } from "./run-stats.logic";
|
|
@@ -43,6 +44,8 @@ import {
|
|
|
43
44
|
isNull,
|
|
44
45
|
isNotNull,
|
|
45
46
|
inArray,
|
|
47
|
+
max,
|
|
48
|
+
count,
|
|
46
49
|
} from "drizzle-orm";
|
|
47
50
|
import { ORPCError } from "@orpc/server";
|
|
48
51
|
import { evaluateHealthStatus } from "./state-evaluator";
|
|
@@ -51,7 +54,9 @@ import { parseHealthEntityId } from "./health-entity-id";
|
|
|
51
54
|
import { stateThresholds } from "./state-thresholds-migrations";
|
|
52
55
|
import type { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
53
56
|
import type { Logger } from "@checkstack/backend-api";
|
|
57
|
+
import { resolveEffectiveEnvironments } from "./effective-environments";
|
|
54
58
|
import { incrementHourlyAggregate } from "./realtime-aggregation";
|
|
59
|
+
import { withScopedTransaction } from "@checkstack/backend-api";
|
|
55
60
|
import type {
|
|
56
61
|
HealthCheckRegistry,
|
|
57
62
|
SafeDatabase,
|
|
@@ -107,6 +112,10 @@ interface SystemCheckStatus {
|
|
|
107
112
|
status: HealthCheckStatus;
|
|
108
113
|
runsConsidered: number;
|
|
109
114
|
lastRunAt?: Date;
|
|
115
|
+
/** Environment slices this check currently fans out to (>= 1). */
|
|
116
|
+
sliceCount: number;
|
|
117
|
+
/** How many of {@link sliceCount} slices are currently non-healthy. */
|
|
118
|
+
failingSliceCount: number;
|
|
110
119
|
}
|
|
111
120
|
|
|
112
121
|
interface SystemHealthStatusResponse {
|
|
@@ -260,6 +269,53 @@ export class HealthCheckService {
|
|
|
260
269
|
return config ? this.mapConfig(config) : undefined;
|
|
261
270
|
}
|
|
262
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Resolve the per-environment slices a (system, config) assignment should
|
|
274
|
+
* enqueue for a ONE-OFF run (the `run_now` automation). Returns the list of
|
|
275
|
+
* environment ids to run, or `[null]` (a single env-less run) when the
|
|
276
|
+
* assignment has no effective environments. Mirrors the executor's fan-out
|
|
277
|
+
* resolution so a manual run covers exactly the same slices the recurring
|
|
278
|
+
* schedule does. Fail-open: a catalog resolution failure collapses to a
|
|
279
|
+
* single env-less run rather than enqueuing nothing.
|
|
280
|
+
*/
|
|
281
|
+
async resolveEnqueueEnvironmentIds(props: {
|
|
282
|
+
systemId: string;
|
|
283
|
+
configurationId: string;
|
|
284
|
+
catalogClient: CatalogClient;
|
|
285
|
+
logger: Logger;
|
|
286
|
+
}): Promise<(string | null)[]> {
|
|
287
|
+
const { systemId, configurationId, catalogClient, logger } = props;
|
|
288
|
+
const [assignment] = await this.db
|
|
289
|
+
.select({ environmentIds: systemHealthChecks.environmentIds })
|
|
290
|
+
.from(systemHealthChecks)
|
|
291
|
+
.where(
|
|
292
|
+
and(
|
|
293
|
+
eq(systemHealthChecks.systemId, systemId),
|
|
294
|
+
eq(systemHealthChecks.configurationId, configurationId),
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
let membership: Awaited<
|
|
299
|
+
ReturnType<CatalogClient["resolveSystemEnvironments"]>
|
|
300
|
+
> = [];
|
|
301
|
+
try {
|
|
302
|
+
membership = await catalogClient.resolveSystemEnvironments({ systemId });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
logger.warn(
|
|
305
|
+
`run_now: could not resolve environments for system ${systemId}`,
|
|
306
|
+
error,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const effectiveEnvs = resolveEffectiveEnvironments({
|
|
311
|
+
environmentIds: assignment?.environmentIds,
|
|
312
|
+
membership,
|
|
313
|
+
});
|
|
314
|
+
return effectiveEnvs.length > 0
|
|
315
|
+
? effectiveEnvs.map((env) => env.id)
|
|
316
|
+
: [null];
|
|
317
|
+
}
|
|
318
|
+
|
|
263
319
|
/**
|
|
264
320
|
* Redact a configuration for a UI/AI read: every `x-secret` field is
|
|
265
321
|
* removed from the strategy config and each collector config. Stored
|
|
@@ -933,6 +989,39 @@ export class HealthCheckService {
|
|
|
933
989
|
return results;
|
|
934
990
|
}
|
|
935
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Count of health-check assignments for each of `systemIds`, keyed by
|
|
994
|
+
* systemId. ONE grouped query (no per-system fan-out) so the catalog manager
|
|
995
|
+
* can badge the whole visible list without an N+1 of {@link
|
|
996
|
+
* getSystemAssociations} - each of those held a pooled connection and
|
|
997
|
+
* contended with the run executor. Every requested system appears in the
|
|
998
|
+
* result (`0` when it has no `system_health_checks` rows, since the grouped
|
|
999
|
+
* query only returns systems that have at least one); the RPC's `recordKey`
|
|
1000
|
+
* gating then drops any keys the caller may not read.
|
|
1001
|
+
*/
|
|
1002
|
+
async getBulkAssignedHealthCheckCounts(
|
|
1003
|
+
systemIds: string[],
|
|
1004
|
+
): Promise<Record<string, number>> {
|
|
1005
|
+
// Seed 0 for every requested system so those with no assignments still
|
|
1006
|
+
// report a count (GROUP BY omits systems with zero rows).
|
|
1007
|
+
const counts: Record<string, number> = {};
|
|
1008
|
+
for (const id of systemIds) counts[id] = 0;
|
|
1009
|
+
|
|
1010
|
+
if (systemIds.length === 0) return counts;
|
|
1011
|
+
|
|
1012
|
+
const rows = await this.db
|
|
1013
|
+
.select({
|
|
1014
|
+
systemId: systemHealthChecks.systemId,
|
|
1015
|
+
assignmentCount: count(),
|
|
1016
|
+
})
|
|
1017
|
+
.from(systemHealthChecks)
|
|
1018
|
+
.where(inArray(systemHealthChecks.systemId, systemIds))
|
|
1019
|
+
.groupBy(systemHealthChecks.systemId);
|
|
1020
|
+
|
|
1021
|
+
for (const row of rows) counts[row.systemId] = row.assignmentCount;
|
|
1022
|
+
return counts;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
936
1025
|
/**
|
|
937
1026
|
* List the IDs of every system an ENABLED assignment of `configurationId`
|
|
938
1027
|
* targets. Used by the pause/resume RPC handlers to know which systems'
|
|
@@ -1030,159 +1119,212 @@ export class HealthCheckService {
|
|
|
1030
1119
|
systemId: string,
|
|
1031
1120
|
environmentId?: string | null,
|
|
1032
1121
|
): Promise<SystemHealthStatusResponse> {
|
|
1033
|
-
//
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
//
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
eq(
|
|
1057
|
-
)
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
const maxWindowSize = 100; // Max configurable window size
|
|
1072
|
-
|
|
1073
|
-
// Environment filter for the per-check run window. `undefined` (rollup)
|
|
1074
|
-
// adds no predicate; `null` filters to the env-less slice; a string
|
|
1075
|
-
// filters to that environment. The lookup index leads with
|
|
1076
|
-
// (system_id, environment_id, …) so the env-scoped query is index-efficient.
|
|
1077
|
-
//
|
|
1078
|
-
// For the rollup, we deliberately do NOT apply a single envFilter to one
|
|
1079
|
-
// flat run list — see the per-association branch below for why.
|
|
1080
|
-
const envFilter =
|
|
1081
|
-
environmentId === undefined
|
|
1082
|
-
? undefined
|
|
1083
|
-
: environmentId === null
|
|
1084
|
-
? isNull(healthCheckRuns.environmentId)
|
|
1085
|
-
: eq(healthCheckRuns.environmentId, environmentId);
|
|
1122
|
+
// §perf: batch the 1 (associations) + N (per-check run window) reads
|
|
1123
|
+
// into ONE scoped transaction so the whole read fan-out pays a single
|
|
1124
|
+
// BEGIN/SET LOCAL/COMMIT and holds one connection, instead of 1+N
|
|
1125
|
+
// standalone scoped queries each checking a connection out. Pure
|
|
1126
|
+
// evaluation runs inside too (it issues no DB). See withScopedTransaction.
|
|
1127
|
+
const checkStatuses = await withScopedTransaction(this.db, async (tx) => {
|
|
1128
|
+
// Get all associations for this system with their thresholds and config names
|
|
1129
|
+
const associations = await tx
|
|
1130
|
+
.select({
|
|
1131
|
+
configurationId: systemHealthChecks.configurationId,
|
|
1132
|
+
stateThresholds: systemHealthChecks.stateThresholds,
|
|
1133
|
+
configName: healthCheckConfigurations.name,
|
|
1134
|
+
enabled: systemHealthChecks.enabled,
|
|
1135
|
+
// The per-assignment environment selector. Drives the rollup's
|
|
1136
|
+
// effective-slice filter below so a per-env slice whose environment
|
|
1137
|
+
// was DISABLED for this assignment (removed from `environmentIds`)
|
|
1138
|
+
// stops dragging the aggregate the instant it is disabled - instead
|
|
1139
|
+
// of lingering until its stale runs age out of the bounded window.
|
|
1140
|
+
environmentIds: systemHealthChecks.environmentIds,
|
|
1141
|
+
})
|
|
1142
|
+
.from(systemHealthChecks)
|
|
1143
|
+
.innerJoin(
|
|
1144
|
+
healthCheckConfigurations,
|
|
1145
|
+
eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
|
|
1146
|
+
)
|
|
1147
|
+
.where(
|
|
1148
|
+
and(
|
|
1149
|
+
eq(systemHealthChecks.systemId, systemId),
|
|
1150
|
+
eq(systemHealthChecks.enabled, true),
|
|
1151
|
+
// A paused configuration contributes no signal to the system's
|
|
1152
|
+
// health: its execution is skipped (see queue-executor pause gate)
|
|
1153
|
+
// and its historical runs MUST NOT keep the aggregate degraded
|
|
1154
|
+
// while it is paused. Excluding it here makes the rollup reflect
|
|
1155
|
+
// only the actively-running checks, so pausing the sole failing
|
|
1156
|
+
// check clears the system's status and downstream SLO downtime.
|
|
1157
|
+
eq(healthCheckConfigurations.paused, false),
|
|
1158
|
+
),
|
|
1159
|
+
);
|
|
1086
1160
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1161
|
+
if (associations.length === 0) return [];
|
|
1162
|
+
|
|
1163
|
+
// For each association, get recent runs and evaluate status
|
|
1164
|
+
const out: SystemCheckStatus[] = [];
|
|
1165
|
+
const maxWindowSize = 100; // Max configurable window size
|
|
1166
|
+
|
|
1167
|
+
// Environment filter for the per-check run window. `undefined` (rollup)
|
|
1168
|
+
// adds no predicate; `null` filters to the env-less slice; a string
|
|
1169
|
+
// filters to that environment. The lookup index leads with
|
|
1170
|
+
// (system_id, environment_id, …) so the env-scoped query is index-efficient.
|
|
1171
|
+
//
|
|
1172
|
+
// For the rollup, we deliberately do NOT apply a single envFilter to one
|
|
1173
|
+
// flat run list — see the per-association branch below for why.
|
|
1174
|
+
const envFilter =
|
|
1175
|
+
environmentId === undefined
|
|
1176
|
+
? undefined
|
|
1177
|
+
: environmentId === null
|
|
1178
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1179
|
+
: eq(healthCheckRuns.environmentId, environmentId);
|
|
1180
|
+
|
|
1181
|
+
for (const assoc of associations) {
|
|
1182
|
+
// Extract and migrate thresholds from versioned config
|
|
1183
|
+
let thresholds: StateThresholds | undefined;
|
|
1184
|
+
if (assoc.stateThresholds) {
|
|
1185
|
+
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
1186
|
+
}
|
|
1093
1187
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
.
|
|
1125
|
-
|
|
1188
|
+
let status: HealthCheckStatus;
|
|
1189
|
+
let runsConsidered: number;
|
|
1190
|
+
let lastRunAt: Date | undefined;
|
|
1191
|
+
// Fan-out accounting for the honest "X of Y checks failing" denominator:
|
|
1192
|
+
// how many environment slices this check currently spans, and how many
|
|
1193
|
+
// are non-healthy. A non-fanned (single-env / env-less) check is one
|
|
1194
|
+
// slice. Populated in both branches so the DTO field is always present.
|
|
1195
|
+
let sliceCount = 1;
|
|
1196
|
+
let failingSliceCount = 0;
|
|
1197
|
+
|
|
1198
|
+
if (environmentId === undefined) {
|
|
1199
|
+
// System rollup: evaluate the threshold window PER ENVIRONMENT within
|
|
1200
|
+
// the association, then take worst-wins ACROSS envs. Flattening every
|
|
1201
|
+
// env's runs into one list feeds interleaved statuses to
|
|
1202
|
+
// `evaluateConsecutive` (the default mode): the streak breaks on the
|
|
1203
|
+
// first interleaving env, so the evaluator collapses to whatever
|
|
1204
|
+
// single env's status the most recent run landed on. That masks any
|
|
1205
|
+
// permanently-failing sibling env in the default mode ("the healthy
|
|
1206
|
+
// env wins"), and flaps healthy↔degraded whenever env insertion
|
|
1207
|
+
// order drifts across ticks (see the regression test
|
|
1208
|
+
// `rollup — worst-wins across environments within an association`).
|
|
1209
|
+
// Per-env evaluation makes the rollup worst-wins stable regardless of
|
|
1210
|
+
// insertion order or multi-pod racing.
|
|
1211
|
+
//
|
|
1212
|
+
// Each env is windowed SEPARATELY (`maxWindowSize` runs PER env), not
|
|
1213
|
+
// via one shared `LIMIT maxWindowSize` across the mixed pool. A shared
|
|
1214
|
+
// window silently truncates a check that fans out to many envs: with
|
|
1215
|
+
// E envs each env sees only ~maxWindowSize/E of its own runs, so a
|
|
1216
|
+
// small consecutive threshold can miss a genuine per-env streak once
|
|
1217
|
+
// E grows. Per-env windows give every environment its full evaluation
|
|
1218
|
+
// depth regardless of how many siblings it has.
|
|
1219
|
+
const distinctEnvRows = await tx
|
|
1220
|
+
.selectDistinct({ environmentId: healthCheckRuns.environmentId })
|
|
1221
|
+
.from(healthCheckRuns)
|
|
1222
|
+
.where(
|
|
1223
|
+
and(
|
|
1224
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1225
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1226
|
+
),
|
|
1227
|
+
);
|
|
1228
|
+
const presentEnvKeys = distinctEnvRows.map(
|
|
1229
|
+
(r) => r.environmentId ?? null,
|
|
1230
|
+
);
|
|
1126
1231
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1232
|
+
// Keep only slices that are still EFFECTIVE for this assignment: a
|
|
1233
|
+
// concrete environment removed from `environmentIds` (the reported
|
|
1234
|
+
// bug - "disable env for assignment"), plus the stale env-less slice
|
|
1235
|
+
// of a check that now fans out, are dropped. Without this a disabled
|
|
1236
|
+
// env's last unhealthy runs keep dragging the rollup via worst-wins,
|
|
1237
|
+
// because no health-change event fires for a slice that stopped
|
|
1238
|
+
// producing runs, so the event-driven rollup consumer never recomputes
|
|
1239
|
+
// it away. The selector is durable Postgres state (`environmentIds`),
|
|
1240
|
+
// so this is catalog-free and returns the same answer on every pod.
|
|
1241
|
+
const effectiveKeys = selectEffectiveEnvKeys({
|
|
1242
|
+
environmentIds: assoc.environmentIds,
|
|
1243
|
+
presentEnvKeys,
|
|
1244
|
+
});
|
|
1139
1245
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1246
|
+
status = "healthy";
|
|
1247
|
+
runsConsidered = 0;
|
|
1248
|
+
lastRunAt = undefined;
|
|
1249
|
+
// Each EFFECTIVE env group is a slice. A check that has runs against N
|
|
1250
|
+
// effective envs currently fans out to N; before it has ever run (no
|
|
1251
|
+
// effective group) it is still one logical slice.
|
|
1252
|
+
sliceCount = Math.max(effectiveKeys.size, 1);
|
|
1253
|
+
failingSliceCount = 0;
|
|
1254
|
+
for (const key of effectiveKeys) {
|
|
1255
|
+
const envRuns = await tx
|
|
1256
|
+
.select({
|
|
1257
|
+
status: healthCheckRuns.status,
|
|
1258
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1259
|
+
})
|
|
1260
|
+
.from(healthCheckRuns)
|
|
1261
|
+
.where(
|
|
1262
|
+
and(
|
|
1263
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1264
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1265
|
+
key === null
|
|
1266
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1267
|
+
: eq(healthCheckRuns.environmentId, key),
|
|
1268
|
+
),
|
|
1269
|
+
)
|
|
1270
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1271
|
+
.limit(maxWindowSize);
|
|
1272
|
+
|
|
1273
|
+
runsConsidered += envRuns.length;
|
|
1274
|
+
const newest = envRuns[0]?.timestamp;
|
|
1275
|
+
if (newest && (!lastRunAt || newest > lastRunAt)) lastRunAt = newest;
|
|
1276
|
+
const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
|
|
1277
|
+
// Count EVERY failing slice (don't break early): the failing count
|
|
1278
|
+
// feeds the dashboard numerator, so all non-healthy envs must tally.
|
|
1279
|
+
if (envStatus !== "healthy") {
|
|
1280
|
+
failingSliceCount++;
|
|
1281
|
+
}
|
|
1282
|
+
if (envStatus === "unhealthy") {
|
|
1283
|
+
status = "unhealthy";
|
|
1284
|
+
} else if (envStatus === "degraded" && status === "healthy") {
|
|
1285
|
+
status = "degraded";
|
|
1286
|
+
}
|
|
1151
1287
|
}
|
|
1288
|
+
} else {
|
|
1289
|
+
// Per-env (string) or env-less (null) slice: that slice's flat run
|
|
1290
|
+
// window is monotonic per-env, so the threshold evaluator sees no
|
|
1291
|
+
// interleaving — the consecutive streak is well-defined.
|
|
1292
|
+
const runs = await tx
|
|
1293
|
+
.select({
|
|
1294
|
+
status: healthCheckRuns.status,
|
|
1295
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1296
|
+
})
|
|
1297
|
+
.from(healthCheckRuns)
|
|
1298
|
+
.where(
|
|
1299
|
+
and(
|
|
1300
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1301
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1302
|
+
...(envFilter ? [envFilter] : []),
|
|
1303
|
+
),
|
|
1304
|
+
)
|
|
1305
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1306
|
+
.limit(maxWindowSize);
|
|
1307
|
+
|
|
1308
|
+
status = evaluateHealthStatus({ runs, thresholds });
|
|
1309
|
+
runsConsidered = runs.length;
|
|
1310
|
+
lastRunAt = runs[0]?.timestamp;
|
|
1311
|
+
// Single-slice evaluation: this env either counts as failing or not.
|
|
1312
|
+
sliceCount = 1;
|
|
1313
|
+
failingSliceCount = status === "healthy" ? 0 : 1;
|
|
1152
1314
|
}
|
|
1153
|
-
} else {
|
|
1154
|
-
// Per-env (string) or env-less (null) slice: that slice's flat run
|
|
1155
|
-
// window is monotonic per-env, so the threshold evaluator sees no
|
|
1156
|
-
// interleaving — the consecutive streak is well-defined.
|
|
1157
|
-
const runs = await this.db
|
|
1158
|
-
.select({
|
|
1159
|
-
status: healthCheckRuns.status,
|
|
1160
|
-
timestamp: healthCheckRuns.timestamp,
|
|
1161
|
-
})
|
|
1162
|
-
.from(healthCheckRuns)
|
|
1163
|
-
.where(
|
|
1164
|
-
and(
|
|
1165
|
-
eq(healthCheckRuns.systemId, systemId),
|
|
1166
|
-
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1167
|
-
...(envFilter ? [envFilter] : []),
|
|
1168
|
-
),
|
|
1169
|
-
)
|
|
1170
|
-
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1171
|
-
.limit(maxWindowSize);
|
|
1172
1315
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1316
|
+
out.push({
|
|
1317
|
+
configurationId: assoc.configurationId,
|
|
1318
|
+
configurationName: assoc.configName,
|
|
1319
|
+
status,
|
|
1320
|
+
runsConsidered,
|
|
1321
|
+
lastRunAt,
|
|
1322
|
+
sliceCount,
|
|
1323
|
+
failingSliceCount,
|
|
1324
|
+
});
|
|
1176
1325
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
configurationId: assoc.configurationId,
|
|
1180
|
-
configurationName: assoc.configName,
|
|
1181
|
-
status,
|
|
1182
|
-
runsConsidered,
|
|
1183
|
-
lastRunAt,
|
|
1184
|
-
});
|
|
1185
|
-
}
|
|
1326
|
+
return out;
|
|
1327
|
+
});
|
|
1186
1328
|
|
|
1187
1329
|
// Aggregate status: worst status wins (unhealthy > degraded > healthy)
|
|
1188
1330
|
let aggregateStatus: HealthCheckStatus = "healthy";
|
|
@@ -1439,157 +1581,237 @@ export class HealthCheckService {
|
|
|
1439
1581
|
* Returns all health checks with their last 25 runs for sparkline visualization.
|
|
1440
1582
|
*/
|
|
1441
1583
|
async getSystemHealthOverview(systemId: string) {
|
|
1442
|
-
//
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
.from(systemHealthChecks)
|
|
1454
|
-
.innerJoin(
|
|
1455
|
-
healthCheckConfigurations,
|
|
1456
|
-
eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
|
|
1457
|
-
)
|
|
1458
|
-
.where(eq(systemHealthChecks.systemId, systemId));
|
|
1459
|
-
|
|
1460
|
-
const checks = [];
|
|
1461
|
-
const sparklineLimit = 25;
|
|
1462
|
-
|
|
1463
|
-
for (const assoc of associations) {
|
|
1464
|
-
// Get last 25 runs for sparkline (newest first, then reverse for chronological display)
|
|
1465
|
-
const runs = await this.db
|
|
1584
|
+
// §perf: batch the 1 (associations) + N·(2+E) (per-check recentRuns +
|
|
1585
|
+
// grouped last-healthy + per-env slice) reads into ONE scoped transaction
|
|
1586
|
+
// so the whole read fan-out pays a single BEGIN/SET LOCAL/COMMIT and holds
|
|
1587
|
+
// one connection, instead of 1+N·(2+E) standalone scoped queries each
|
|
1588
|
+
// checking a connection out. Only pure CPU (stateThresholds.parse,
|
|
1589
|
+
// evaluateHealthStatus, selectEffectiveEnvKeys) sits between the queries —
|
|
1590
|
+
// no DB-external await — so wrapping is safe. This mirrors the sibling
|
|
1591
|
+
// getSystemHealthStatus above. See withScopedTransaction.
|
|
1592
|
+
const checks = await withScopedTransaction(this.db, async (tx) => {
|
|
1593
|
+
// Get all associations with config details
|
|
1594
|
+
const associations = await tx
|
|
1466
1595
|
.select({
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1596
|
+
configurationId: systemHealthChecks.configurationId,
|
|
1597
|
+
configName: healthCheckConfigurations.name,
|
|
1598
|
+
strategyId: healthCheckConfigurations.strategyId,
|
|
1599
|
+
intervalSeconds: healthCheckConfigurations.intervalSeconds,
|
|
1600
|
+
enabled: systemHealthChecks.enabled,
|
|
1601
|
+
paused: healthCheckConfigurations.paused,
|
|
1602
|
+
stateThresholds: systemHealthChecks.stateThresholds,
|
|
1603
|
+
// The per-assignment environment selector, surfaced so the check-level
|
|
1604
|
+
// rollup status here excludes slices whose env was disabled for this
|
|
1605
|
+
// assignment, and so the response can carry it to the frontend orphan
|
|
1606
|
+
// detection (a disabled-for-assignment env is tucked under "Old checks"
|
|
1607
|
+
// even though it is still part of the system's membership).
|
|
1608
|
+
environmentIds: systemHealthChecks.environmentIds,
|
|
1471
1609
|
})
|
|
1472
|
-
.from(
|
|
1473
|
-
.
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1477
|
-
),
|
|
1610
|
+
.from(systemHealthChecks)
|
|
1611
|
+
.innerJoin(
|
|
1612
|
+
healthCheckConfigurations,
|
|
1613
|
+
eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
|
|
1478
1614
|
)
|
|
1479
|
-
.
|
|
1480
|
-
.limit(sparklineLimit);
|
|
1615
|
+
.where(eq(systemHealthChecks.systemId, systemId));
|
|
1481
1616
|
|
|
1482
|
-
|
|
1483
|
-
const
|
|
1617
|
+
const checks = [];
|
|
1618
|
+
const sparklineLimit = 25;
|
|
1484
1619
|
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
// Group the fetched runs by environmentId (null = env-less slice). We
|
|
1492
|
-
// query each env's slice separately below to evaluate it on its own
|
|
1493
|
-
// monotonic run window and worst-wins across envs — this is the same
|
|
1494
|
-
// derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
|
|
1495
|
-
// that method for the rationale (flattening envs feeds interleaved
|
|
1496
|
-
// statuses to the consecutive evaluator and masks sibling outages).
|
|
1497
|
-
const perEnvironment: {
|
|
1498
|
-
environmentId: string | null;
|
|
1499
|
-
status: HealthCheckStatus;
|
|
1500
|
-
recentRuns: { id: string; status: HealthCheckStatus; timestamp: Date }[];
|
|
1501
|
-
}[] = [];
|
|
1502
|
-
|
|
1503
|
-
// Stable ordering of env keys: env-less (`null`) first, then env ids in
|
|
1504
|
-
// the order they were first encountered in the mixed pool (membership
|
|
1505
|
-
// order is otherwise unobservable here without a catalog read; recent
|
|
1506
|
-
// runs surface stable, recent order).
|
|
1507
|
-
const envKeys: (string | null)[] = [];
|
|
1508
|
-
const seenEnv = new Set<string | null>();
|
|
1509
|
-
for (const r of runs) {
|
|
1510
|
-
const key = r.environmentId ?? null;
|
|
1511
|
-
if (!seenEnv.has(key)) {
|
|
1512
|
-
seenEnv.add(key);
|
|
1513
|
-
envKeys.push(key);
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
// If no runs at all, surface a single env-less entry so UI can render
|
|
1517
|
-
// an empty row rather than nothing.
|
|
1518
|
-
if (envKeys.length === 0) envKeys.push(null);
|
|
1519
|
-
|
|
1520
|
-
let aggregateStatus: HealthCheckStatus = "healthy";
|
|
1521
|
-
for (const envId of envKeys) {
|
|
1522
|
-
const envRuns = await this.db
|
|
1620
|
+
for (const assoc of associations) {
|
|
1621
|
+
// Get last 25 runs for sparkline (newest first, then reverse for chronological display)
|
|
1622
|
+
const runs = await tx
|
|
1523
1623
|
.select({
|
|
1524
1624
|
id: healthCheckRuns.id,
|
|
1525
1625
|
status: healthCheckRuns.status,
|
|
1526
1626
|
timestamp: healthCheckRuns.timestamp,
|
|
1627
|
+
environmentId: healthCheckRuns.environmentId,
|
|
1527
1628
|
})
|
|
1528
1629
|
.from(healthCheckRuns)
|
|
1529
1630
|
.where(
|
|
1530
1631
|
and(
|
|
1531
1632
|
eq(healthCheckRuns.systemId, systemId),
|
|
1532
1633
|
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1533
|
-
envId === null
|
|
1534
|
-
? isNull(healthCheckRuns.environmentId)
|
|
1535
|
-
: eq(healthCheckRuns.environmentId, envId),
|
|
1536
1634
|
),
|
|
1537
1635
|
)
|
|
1538
1636
|
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1539
1637
|
.limit(sparklineLimit);
|
|
1540
1638
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1639
|
+
// Reverse to chronological order (oldest first) for sparkline display
|
|
1640
|
+
const chronologicalRuns = runs.toReversed();
|
|
1641
|
+
|
|
1642
|
+
// Migrate and extract thresholds
|
|
1643
|
+
let thresholds: StateThresholds | undefined;
|
|
1644
|
+
if (assoc.stateThresholds) {
|
|
1645
|
+
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Most recent HEALTHY run per environment, computed OUTSIDE the bounded
|
|
1649
|
+
// sparkline window so "last successful run" stays correct even when a
|
|
1650
|
+
// check has been failing for far longer than the last 25 runs. One
|
|
1651
|
+
// grouped aggregate query per check (env-less = the `null` group). The
|
|
1652
|
+
// (system_id, configuration_id, environment_id, timestamp) index makes
|
|
1653
|
+
// this a cheap max-per-group scan.
|
|
1654
|
+
const lastHealthyRows = await tx
|
|
1655
|
+
.select({
|
|
1656
|
+
environmentId: healthCheckRuns.environmentId,
|
|
1657
|
+
lastSuccessAt: max(healthCheckRuns.timestamp),
|
|
1658
|
+
})
|
|
1659
|
+
.from(healthCheckRuns)
|
|
1660
|
+
.where(
|
|
1661
|
+
and(
|
|
1662
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1663
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1664
|
+
eq(healthCheckRuns.status, "healthy"),
|
|
1665
|
+
),
|
|
1666
|
+
)
|
|
1667
|
+
.groupBy(healthCheckRuns.environmentId);
|
|
1668
|
+
const lastHealthyByEnv = new Map<string | null, Date>();
|
|
1669
|
+
let checkLastSuccessfulRunAt: Date | undefined;
|
|
1670
|
+
for (const row of lastHealthyRows) {
|
|
1671
|
+
if (!row.lastSuccessAt) continue;
|
|
1672
|
+
lastHealthyByEnv.set(row.environmentId ?? null, row.lastSuccessAt);
|
|
1673
|
+
if (
|
|
1674
|
+
!checkLastSuccessfulRunAt ||
|
|
1675
|
+
row.lastSuccessAt > checkLastSuccessfulRunAt
|
|
1676
|
+
) {
|
|
1677
|
+
checkLastSuccessfulRunAt = row.lastSuccessAt;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Group the fetched runs by environmentId (null = env-less slice). We
|
|
1682
|
+
// query each env's slice separately below to evaluate it on its own
|
|
1683
|
+
// monotonic run window and worst-wins across envs — this is the same
|
|
1684
|
+
// derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
|
|
1685
|
+
// that method for the rationale (flattening envs feeds interleaved
|
|
1686
|
+
// statuses to the consecutive evaluator and masks sibling outages).
|
|
1687
|
+
const perEnvironment: {
|
|
1688
|
+
environmentId: string | null;
|
|
1689
|
+
status: HealthCheckStatus;
|
|
1690
|
+
lastSuccessfulRunAt?: Date;
|
|
1691
|
+
recentRuns: {
|
|
1692
|
+
id: string;
|
|
1693
|
+
status: HealthCheckStatus;
|
|
1694
|
+
timestamp: Date;
|
|
1695
|
+
}[];
|
|
1696
|
+
}[] = [];
|
|
1697
|
+
|
|
1698
|
+
// Stable ordering of env keys: env-less (`null`) first, then env ids in
|
|
1699
|
+
// the order they were first encountered in the mixed pool (membership
|
|
1700
|
+
// order is otherwise unobservable here without a catalog read; recent
|
|
1701
|
+
// runs surface stable, recent order).
|
|
1702
|
+
const envKeys: (string | null)[] = [];
|
|
1703
|
+
const seenEnv = new Set<string | null>();
|
|
1704
|
+
for (const r of runs) {
|
|
1705
|
+
const key = r.environmentId ?? null;
|
|
1706
|
+
if (!seenEnv.has(key)) {
|
|
1707
|
+
seenEnv.add(key);
|
|
1708
|
+
envKeys.push(key);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
// If no runs at all, surface a single env-less entry so UI can render
|
|
1712
|
+
// an empty row rather than nothing.
|
|
1713
|
+
if (envKeys.length === 0) envKeys.push(null);
|
|
1714
|
+
|
|
1715
|
+
// The slices that currently CONTRIBUTE to this check's rollup status: a
|
|
1716
|
+
// concrete env removed from `environmentIds` (disabled for the assignment)
|
|
1717
|
+
// and the stale env-less slice of a check that now fans out are excluded,
|
|
1718
|
+
// mirroring `getSystemHealthStatus`. The orphaned slices are still emitted
|
|
1719
|
+
// in `perEnvironment` (the frontend tucks them under "Old checks"); they
|
|
1720
|
+
// just no longer drag the check-level worst-wins `status`.
|
|
1721
|
+
const effectiveKeys = selectEffectiveEnvKeys({
|
|
1722
|
+
environmentIds: assoc.environmentIds,
|
|
1723
|
+
presentEnvKeys: envKeys,
|
|
1544
1724
|
});
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1725
|
+
|
|
1726
|
+
let aggregateStatus: HealthCheckStatus = "healthy";
|
|
1727
|
+
for (const envId of envKeys) {
|
|
1728
|
+
const envRuns = await tx
|
|
1729
|
+
.select({
|
|
1730
|
+
id: healthCheckRuns.id,
|
|
1731
|
+
status: healthCheckRuns.status,
|
|
1732
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1733
|
+
})
|
|
1734
|
+
.from(healthCheckRuns)
|
|
1735
|
+
.where(
|
|
1736
|
+
and(
|
|
1737
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1738
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1739
|
+
envId === null
|
|
1740
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1741
|
+
: eq(healthCheckRuns.environmentId, envId),
|
|
1742
|
+
),
|
|
1743
|
+
)
|
|
1744
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1745
|
+
.limit(sparklineLimit);
|
|
1746
|
+
|
|
1747
|
+
const envStatus = evaluateHealthStatus({
|
|
1748
|
+
runs: envRuns,
|
|
1749
|
+
thresholds,
|
|
1750
|
+
});
|
|
1751
|
+
// Worst-wins across EFFECTIVE envs (unhealthy > degraded > healthy).
|
|
1752
|
+
// An orphaned slice's status is still reported per-env but must not
|
|
1753
|
+
// move the aggregate.
|
|
1754
|
+
if (effectiveKeys.has(envId)) {
|
|
1755
|
+
if (envStatus === "unhealthy") {
|
|
1756
|
+
aggregateStatus = "unhealthy";
|
|
1757
|
+
} else if (
|
|
1758
|
+
envStatus === "degraded" &&
|
|
1759
|
+
aggregateStatus === "healthy"
|
|
1760
|
+
) {
|
|
1761
|
+
aggregateStatus = "degraded";
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
perEnvironment.push({
|
|
1766
|
+
environmentId: envId,
|
|
1767
|
+
status: envStatus,
|
|
1768
|
+
lastSuccessfulRunAt: lastHealthyByEnv.get(envId),
|
|
1769
|
+
recentRuns: envRuns.toReversed().map((r) => ({
|
|
1770
|
+
id: r.id,
|
|
1771
|
+
status: r.status,
|
|
1772
|
+
timestamp: r.timestamp,
|
|
1773
|
+
})),
|
|
1774
|
+
});
|
|
1550
1775
|
}
|
|
1551
1776
|
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1777
|
+
// Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects).
|
|
1778
|
+
// For a paused configuration the runs are stale (execution is skipped),
|
|
1779
|
+
// so the evaluated `status` is NOT a meaningful current verdict — the
|
|
1780
|
+
// frontend renders a "Paused" pill from the `paused` flag instead.
|
|
1781
|
+
// We still compute it so the historical/sparkline path stays uniform,
|
|
1782
|
+
// and so a non-paused consumer that ignores `paused` sees a best-
|
|
1783
|
+
// effort status rather than a hard null. `aggregateStatus` is the
|
|
1784
|
+
// worst-wins-across-envs rollup derived above (it equals what
|
|
1785
|
+
// evaluateHealthStatus would return on the flat pool if only ONE env is
|
|
1786
|
+
// present, preserving per-check single-env behavior).
|
|
1787
|
+
const status = aggregateStatus;
|
|
1788
|
+
|
|
1789
|
+
checks.push({
|
|
1790
|
+
configurationId: assoc.configurationId,
|
|
1791
|
+
configurationName: assoc.configName,
|
|
1792
|
+
strategyId: assoc.strategyId,
|
|
1793
|
+
intervalSeconds: assoc.intervalSeconds,
|
|
1794
|
+
enabled: assoc.enabled,
|
|
1795
|
+
paused: assoc.paused,
|
|
1796
|
+
status,
|
|
1797
|
+
stateThresholds: thresholds,
|
|
1798
|
+
// Surface the per-assignment environment selector so the frontend can
|
|
1799
|
+
// treat a slice whose env was disabled for THIS assignment as orphaned
|
|
1800
|
+
// (system membership alone can't tell - the env is still in the system).
|
|
1801
|
+
environmentIds: assoc.environmentIds,
|
|
1802
|
+
lastSuccessfulRunAt: checkLastSuccessfulRunAt,
|
|
1803
|
+
recentRuns: chronologicalRuns.map((r) => ({
|
|
1556
1804
|
id: r.id,
|
|
1557
1805
|
status: r.status,
|
|
1558
1806
|
timestamp: r.timestamp,
|
|
1807
|
+
environmentId: r.environmentId,
|
|
1559
1808
|
})),
|
|
1809
|
+
perEnvironment,
|
|
1560
1810
|
});
|
|
1561
1811
|
}
|
|
1562
1812
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
// so the evaluated `status` is NOT a meaningful current verdict — the
|
|
1566
|
-
// frontend renders a "Paused" pill from the `paused` flag instead.
|
|
1567
|
-
// We still compute it so the historical/sparkline path stays uniform,
|
|
1568
|
-
// and so a non-paused consumer that ignores `paused` sees a best-
|
|
1569
|
-
// effort status rather than a hard null. `aggregateStatus` is the
|
|
1570
|
-
// worst-wins-across-envs rollup derived above (it equals what
|
|
1571
|
-
// evaluateHealthStatus would return on the flat pool if only ONE env is
|
|
1572
|
-
// present, preserving per-check single-env behavior).
|
|
1573
|
-
const status = aggregateStatus;
|
|
1574
|
-
|
|
1575
|
-
checks.push({
|
|
1576
|
-
configurationId: assoc.configurationId,
|
|
1577
|
-
configurationName: assoc.configName,
|
|
1578
|
-
strategyId: assoc.strategyId,
|
|
1579
|
-
intervalSeconds: assoc.intervalSeconds,
|
|
1580
|
-
enabled: assoc.enabled,
|
|
1581
|
-
paused: assoc.paused,
|
|
1582
|
-
status,
|
|
1583
|
-
stateThresholds: thresholds,
|
|
1584
|
-
recentRuns: chronologicalRuns.map((r) => ({
|
|
1585
|
-
id: r.id,
|
|
1586
|
-
status: r.status,
|
|
1587
|
-
timestamp: r.timestamp,
|
|
1588
|
-
environmentId: r.environmentId,
|
|
1589
|
-
})),
|
|
1590
|
-
perEnvironment,
|
|
1591
|
-
});
|
|
1592
|
-
}
|
|
1813
|
+
return checks;
|
|
1814
|
+
});
|
|
1593
1815
|
|
|
1594
1816
|
return { systemId, checks };
|
|
1595
1817
|
}
|
|
@@ -1704,6 +1926,7 @@ export class HealthCheckService {
|
|
|
1704
1926
|
sourceFilter?: string;
|
|
1705
1927
|
statusFilter?: HealthCheckStatus[];
|
|
1706
1928
|
environmentId?: string | null;
|
|
1929
|
+
environmentIds?: string[];
|
|
1707
1930
|
maxBuckets?: number;
|
|
1708
1931
|
}): Promise<RunStats> {
|
|
1709
1932
|
const {
|
|
@@ -1714,6 +1937,7 @@ export class HealthCheckService {
|
|
|
1714
1937
|
sourceFilter,
|
|
1715
1938
|
statusFilter,
|
|
1716
1939
|
environmentId,
|
|
1940
|
+
environmentIds,
|
|
1717
1941
|
maxBuckets = 24,
|
|
1718
1942
|
} = props;
|
|
1719
1943
|
|
|
@@ -1738,6 +1962,12 @@ export class HealthCheckService {
|
|
|
1738
1962
|
} else if (environmentId !== undefined) {
|
|
1739
1963
|
conditions.push(eq(healthCheckRuns.environmentId, environmentId));
|
|
1740
1964
|
}
|
|
1965
|
+
// Set-of-environments filter (the status page's per-page env scope). Only
|
|
1966
|
+
// counts runs tagged with one of the selected envs; env-less runs are
|
|
1967
|
+
// excluded (they belong to no published environment).
|
|
1968
|
+
if (environmentIds && environmentIds.length > 0) {
|
|
1969
|
+
conditions.push(inArray(healthCheckRuns.environmentId, environmentIds));
|
|
1970
|
+
}
|
|
1741
1971
|
|
|
1742
1972
|
const rows = await this.db
|
|
1743
1973
|
.select({
|
|
@@ -1757,6 +1987,67 @@ export class HealthCheckService {
|
|
|
1757
1987
|
return summarizeRuns({ runs, startDate, endDate, maxBuckets });
|
|
1758
1988
|
}
|
|
1759
1989
|
|
|
1990
|
+
/**
|
|
1991
|
+
* Bulk variant of {@link getRunStats}: compute the compact stats summary for
|
|
1992
|
+
* MANY systems over one shared window in a SINGLE grouped read, instead of an
|
|
1993
|
+
* N+1 fan-out of per-system `getRunStats` calls. Fetches every matching run
|
|
1994
|
+
* for all `systemIds` with one `inArray` query, groups by systemId in JS, and
|
|
1995
|
+
* runs the same pure `summarizeRuns` per system so each entry is identical to
|
|
1996
|
+
* what `getRunStats({ systemId })` would return. Systems with no runs in the
|
|
1997
|
+
* window are OMITTED from the record (they simply have no rows to group),
|
|
1998
|
+
* matching the single endpoint's "no runs => nothing to report" semantics.
|
|
1999
|
+
*/
|
|
2000
|
+
async getBulkRunStats(props: {
|
|
2001
|
+
systemIds: string[];
|
|
2002
|
+
startDate: Date;
|
|
2003
|
+
endDate: Date;
|
|
2004
|
+
environmentIds?: string[];
|
|
2005
|
+
maxBuckets?: number;
|
|
2006
|
+
}): Promise<Record<string, RunStats>> {
|
|
2007
|
+
const { systemIds, startDate, endDate, environmentIds, maxBuckets = 24 } =
|
|
2008
|
+
props;
|
|
2009
|
+
if (systemIds.length === 0) return {};
|
|
2010
|
+
|
|
2011
|
+
const conditions = [
|
|
2012
|
+
inArray(healthCheckRuns.systemId, systemIds),
|
|
2013
|
+
gte(healthCheckRuns.timestamp, startDate),
|
|
2014
|
+
lte(healthCheckRuns.timestamp, endDate),
|
|
2015
|
+
];
|
|
2016
|
+
// Set-of-environments filter (the status page's per-page env scope): count
|
|
2017
|
+
// only runs tagged with one of the selected envs; env-less runs excluded.
|
|
2018
|
+
if (environmentIds && environmentIds.length > 0) {
|
|
2019
|
+
conditions.push(inArray(healthCheckRuns.environmentId, environmentIds));
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const rows = await this.db
|
|
2023
|
+
.select({
|
|
2024
|
+
systemId: healthCheckRuns.systemId,
|
|
2025
|
+
timestamp: healthCheckRuns.timestamp,
|
|
2026
|
+
status: healthCheckRuns.status,
|
|
2027
|
+
latencyMs: healthCheckRuns.latencyMs,
|
|
2028
|
+
})
|
|
2029
|
+
.from(healthCheckRuns)
|
|
2030
|
+
.where(and(...conditions));
|
|
2031
|
+
|
|
2032
|
+
const bySystem = new Map<string, StatRun[]>();
|
|
2033
|
+
for (const r of rows) {
|
|
2034
|
+
const list = bySystem.get(r.systemId);
|
|
2035
|
+
const run: StatRun = {
|
|
2036
|
+
timestamp: r.timestamp,
|
|
2037
|
+
status: r.status,
|
|
2038
|
+
latencyMs: r.latencyMs ?? undefined,
|
|
2039
|
+
};
|
|
2040
|
+
if (list) list.push(run);
|
|
2041
|
+
else bySystem.set(r.systemId, [run]);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const out: Record<string, RunStats> = {};
|
|
2045
|
+
for (const [systemId, runs] of bySystem) {
|
|
2046
|
+
out[systemId] = summarizeRuns({ runs, startDate, endDate, maxBuckets });
|
|
2047
|
+
}
|
|
2048
|
+
return out;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1760
2051
|
/**
|
|
1761
2052
|
* Get detailed health check run history with full result data.
|
|
1762
2053
|
* Restricted to users with manage access.
|