@checkstack/healthcheck-backend 1.18.0 → 1.20.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 +484 -0
- package/drizzle/0019_chemical_frightful_four.sql +8 -0
- package/drizzle/0020_certain_mordo.sql +2 -0
- package/drizzle/meta/0019_snapshot.json +661 -0
- package/drizzle/meta/0020_snapshot.json +711 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +23 -21
- package/src/ai/system-signals-contributor.test.ts +33 -9
- package/src/ai/system-signals-contributor.ts +38 -16
- package/src/cache-test-stub.ts +26 -0
- package/src/cache.test.ts +291 -0
- package/src/cache.ts +204 -34
- package/src/health-notification-content.test.ts +111 -0
- package/src/health-notification-content.ts +145 -0
- package/src/healthcheck-gitops-kinds.test.ts +14 -0
- package/src/healthcheck-gitops-kinds.ts +27 -0
- package/src/index.ts +31 -12
- package/src/queue-executor.test.ts +13 -26
- package/src/queue-executor.ts +125 -112
- package/src/retention-job.ts +8 -0
- package/src/rollup-consumer.test.ts +19 -8
- package/src/router-config-secrets.test.ts +2 -7
- package/src/router-create-and-assign.test.ts +2 -7
- package/src/router-pause-recompute.test.ts +2 -7
- package/src/router.test.ts +3 -8
- package/src/router.ts +43 -15
- package/src/schema.ts +74 -31
- package/src/service-batching.test.ts +8 -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 +6 -2
- package/src/service-paused-filter.test.ts +13 -0
- package/src/service-rollup-worst-wins.test.ts +209 -145
- package/src/service.ts +408 -284
- package/src/status-fingerprint.test.ts +92 -0
- package/src/status-fingerprint.ts +66 -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 +387 -0
- package/src/status-page/widgets.ts +236 -39
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";
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
isNotNull,
|
|
45
46
|
inArray,
|
|
46
47
|
max,
|
|
48
|
+
count,
|
|
47
49
|
} from "drizzle-orm";
|
|
48
50
|
import { ORPCError } from "@orpc/server";
|
|
49
51
|
import { evaluateHealthStatus } from "./state-evaluator";
|
|
@@ -987,6 +989,39 @@ export class HealthCheckService {
|
|
|
987
989
|
return results;
|
|
988
990
|
}
|
|
989
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
|
+
|
|
990
1025
|
/**
|
|
991
1026
|
* List the IDs of every system an ENABLED assignment of `configurationId`
|
|
992
1027
|
* targets. Used by the pause/resume RPC handlers to know which systems'
|
|
@@ -1097,6 +1132,12 @@ export class HealthCheckService {
|
|
|
1097
1132
|
stateThresholds: systemHealthChecks.stateThresholds,
|
|
1098
1133
|
configName: healthCheckConfigurations.name,
|
|
1099
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,
|
|
1100
1141
|
})
|
|
1101
1142
|
.from(systemHealthChecks)
|
|
1102
1143
|
.innerJoin(
|
|
@@ -1125,8 +1166,9 @@ export class HealthCheckService {
|
|
|
1125
1166
|
|
|
1126
1167
|
// Environment filter for the per-check run window. `undefined` (rollup)
|
|
1127
1168
|
// adds no predicate; `null` filters to the env-less slice; a string
|
|
1128
|
-
// filters to that environment.
|
|
1129
|
-
// (system_id, environment_id,
|
|
1169
|
+
// filters to that environment. `health_check_runs_slice_recent_idx`
|
|
1170
|
+
// (system_id, configuration_id, environment_id, timestamp) serves the
|
|
1171
|
+
// env-scoped ORDER BY timestamp DESC LIMIT read as an index range scan.
|
|
1130
1172
|
//
|
|
1131
1173
|
// For the rollup, we deliberately do NOT apply a single envFilter to one
|
|
1132
1174
|
// flat run list — see the per-association branch below for why.
|
|
@@ -1167,44 +1209,71 @@ export class HealthCheckService {
|
|
|
1167
1209
|
// `rollup — worst-wins across environments within an association`).
|
|
1168
1210
|
// Per-env evaluation makes the rollup worst-wins stable regardless of
|
|
1169
1211
|
// insertion order or multi-pod racing.
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1212
|
+
//
|
|
1213
|
+
// Each env is windowed SEPARATELY (`maxWindowSize` runs PER env), not
|
|
1214
|
+
// via one shared `LIMIT maxWindowSize` across the mixed pool. A shared
|
|
1215
|
+
// window silently truncates a check that fans out to many envs: with
|
|
1216
|
+
// E envs each env sees only ~maxWindowSize/E of its own runs, so a
|
|
1217
|
+
// small consecutive threshold can miss a genuine per-env streak once
|
|
1218
|
+
// E grows. Per-env windows give every environment its full evaluation
|
|
1219
|
+
// depth regardless of how many siblings it has.
|
|
1220
|
+
const distinctEnvRows = await tx
|
|
1221
|
+
.selectDistinct({ environmentId: healthCheckRuns.environmentId })
|
|
1176
1222
|
.from(healthCheckRuns)
|
|
1177
1223
|
.where(
|
|
1178
1224
|
and(
|
|
1179
1225
|
eq(healthCheckRuns.systemId, systemId),
|
|
1180
1226
|
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1181
1227
|
),
|
|
1182
|
-
)
|
|
1183
|
-
|
|
1184
|
-
|
|
1228
|
+
);
|
|
1229
|
+
const presentEnvKeys = distinctEnvRows.map(
|
|
1230
|
+
(r) => r.environmentId ?? null,
|
|
1231
|
+
);
|
|
1185
1232
|
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1233
|
+
// Keep only slices that are still EFFECTIVE for this assignment: a
|
|
1234
|
+
// concrete environment removed from `environmentIds` (the reported
|
|
1235
|
+
// bug - "disable env for assignment"), plus the stale env-less slice
|
|
1236
|
+
// of a check that now fans out, are dropped. Without this a disabled
|
|
1237
|
+
// env's last unhealthy runs keep dragging the rollup via worst-wins,
|
|
1238
|
+
// because no health-change event fires for a slice that stopped
|
|
1239
|
+
// producing runs, so the event-driven rollup consumer never recomputes
|
|
1240
|
+
// it away. The selector is durable Postgres state (`environmentIds`),
|
|
1241
|
+
// so this is catalog-free and returns the same answer on every pod.
|
|
1242
|
+
const effectiveKeys = selectEffectiveEnvKeys({
|
|
1243
|
+
environmentIds: assoc.environmentIds,
|
|
1244
|
+
presentEnvKeys,
|
|
1245
|
+
});
|
|
1198
1246
|
|
|
1199
1247
|
status = "healthy";
|
|
1200
|
-
runsConsidered =
|
|
1201
|
-
lastRunAt =
|
|
1202
|
-
// Each env group is a slice. A check that has runs against N
|
|
1203
|
-
// currently fans out to N; before it has ever run
|
|
1204
|
-
//
|
|
1205
|
-
sliceCount = Math.max(
|
|
1248
|
+
runsConsidered = 0;
|
|
1249
|
+
lastRunAt = undefined;
|
|
1250
|
+
// Each EFFECTIVE env group is a slice. A check that has runs against N
|
|
1251
|
+
// effective envs currently fans out to N; before it has ever run (no
|
|
1252
|
+
// effective group) it is still one logical slice.
|
|
1253
|
+
sliceCount = Math.max(effectiveKeys.size, 1);
|
|
1206
1254
|
failingSliceCount = 0;
|
|
1207
|
-
for (const
|
|
1255
|
+
for (const key of effectiveKeys) {
|
|
1256
|
+
const envRuns = await tx
|
|
1257
|
+
.select({
|
|
1258
|
+
status: healthCheckRuns.status,
|
|
1259
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1260
|
+
})
|
|
1261
|
+
.from(healthCheckRuns)
|
|
1262
|
+
.where(
|
|
1263
|
+
and(
|
|
1264
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1265
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1266
|
+
key === null
|
|
1267
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1268
|
+
: eq(healthCheckRuns.environmentId, key),
|
|
1269
|
+
),
|
|
1270
|
+
)
|
|
1271
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1272
|
+
.limit(maxWindowSize);
|
|
1273
|
+
|
|
1274
|
+
runsConsidered += envRuns.length;
|
|
1275
|
+
const newest = envRuns[0]?.timestamp;
|
|
1276
|
+
if (newest && (!lastRunAt || newest > lastRunAt)) lastRunAt = newest;
|
|
1208
1277
|
const envStatus = evaluateHealthStatus({ runs: envRuns, thresholds });
|
|
1209
1278
|
// Count EVERY failing slice (don't break early): the failing count
|
|
1210
1279
|
// feeds the dashboard numerator, so all non-healthy envs must tally.
|
|
@@ -1289,17 +1358,10 @@ export class HealthCheckService {
|
|
|
1289
1358
|
* list and no process-local state.
|
|
1290
1359
|
*/
|
|
1291
1360
|
async getAllUnhealthySystemStatuses(): Promise<HealthcheckSignalStatuses> {
|
|
1292
|
-
|
|
1293
|
-
// `getSystemHealthStatus` already short-circuits to healthy for systems
|
|
1294
|
-
// with no enabled associations, so this is the complete candidate set.
|
|
1295
|
-
const rows = await this.db
|
|
1296
|
-
.selectDistinct({ systemId: systemHealthChecks.systemId })
|
|
1297
|
-
.from(systemHealthChecks)
|
|
1298
|
-
.where(eq(systemHealthChecks.enabled, true));
|
|
1299
|
-
|
|
1361
|
+
const systemIds = await this.getUnhealthyCandidateSystemIds();
|
|
1300
1362
|
const result: HealthcheckSignalStatuses = {};
|
|
1301
1363
|
await Promise.all(
|
|
1302
|
-
|
|
1364
|
+
systemIds.map(async (systemId) => {
|
|
1303
1365
|
const status = await this.getSystemHealthStatus(systemId);
|
|
1304
1366
|
if (status.status === "healthy") return; // problems only
|
|
1305
1367
|
result[systemId] = status;
|
|
@@ -1308,6 +1370,24 @@ export class HealthCheckService {
|
|
|
1308
1370
|
return result;
|
|
1309
1371
|
}
|
|
1310
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* The candidate set for a global problem scan: distinct systemIds that have at
|
|
1375
|
+
* least one ENABLED check association. `getSystemHealthStatus` already
|
|
1376
|
+
* short-circuits to healthy for systems with no enabled associations, so this
|
|
1377
|
+
* is the complete set of systems that could be degraded/unhealthy. Split out
|
|
1378
|
+
* so callers can route the per-system status reads through the SHARED CACHE
|
|
1379
|
+
* (`HealthCheckCache.readBulk`) instead of the uncached N+1 that
|
|
1380
|
+
* {@link getAllUnhealthySystemStatuses} runs. Derives only from the durable
|
|
1381
|
+
* `system_health_checks` table, so it answers identically on every pod.
|
|
1382
|
+
*/
|
|
1383
|
+
async getUnhealthyCandidateSystemIds(): Promise<string[]> {
|
|
1384
|
+
const rows = await this.db
|
|
1385
|
+
.selectDistinct({ systemId: systemHealthChecks.systemId })
|
|
1386
|
+
.from(systemHealthChecks)
|
|
1387
|
+
.where(eq(systemHealthChecks.enabled, true));
|
|
1388
|
+
return rows.map((r) => r.systemId);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1311
1391
|
/**
|
|
1312
1392
|
* Live health-state snapshot for a single system (Wave-2 sensing
|
|
1313
1393
|
* contract). When `configurationId` is given, status reflects that
|
|
@@ -1417,95 +1497,26 @@ export class HealthCheckService {
|
|
|
1417
1497
|
}
|
|
1418
1498
|
|
|
1419
1499
|
/**
|
|
1420
|
-
*
|
|
1421
|
-
*
|
|
1422
|
-
*
|
|
1423
|
-
*
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
* rollup deliberately hides a single failing environment.
|
|
1427
|
-
*
|
|
1428
|
-
* Cost scales with the number of environments each system actually fans out
|
|
1429
|
-
* to (`1 + #envs` status evaluations per system); systems with only env-less
|
|
1430
|
-
* runs cost the same as a plain rollup read. Not on any per-run hot path.
|
|
1500
|
+
* Distinct environment ids a system currently has runs for (the env-less
|
|
1501
|
+
* slice is excluded — it is folded into the rollup and is never a real
|
|
1502
|
+
* environment id). Used by the status cache's `readMatrix` to enumerate which
|
|
1503
|
+
* per-environment slices to read (each slice is then served from the same
|
|
1504
|
+
* per-env cache the badge path warms), so the bulk matrix no longer fans out
|
|
1505
|
+
* uncached `getSystemHealthStatus` calls of its own.
|
|
1431
1506
|
*/
|
|
1432
|
-
async
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
}
|
|
1446
|
-
>
|
|
1447
|
-
> {
|
|
1448
|
-
const result: Record<
|
|
1449
|
-
string,
|
|
1450
|
-
{
|
|
1451
|
-
status: HealthCheckStatus;
|
|
1452
|
-
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1453
|
-
environments: Record<
|
|
1454
|
-
string,
|
|
1455
|
-
{
|
|
1456
|
-
status: HealthCheckStatus;
|
|
1457
|
-
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1458
|
-
}
|
|
1459
|
-
>;
|
|
1460
|
-
}
|
|
1461
|
-
> = {};
|
|
1462
|
-
|
|
1463
|
-
await Promise.all(
|
|
1464
|
-
systemIds.map(async (systemId) => {
|
|
1465
|
-
const overall = await this.getSystemHealthStatus(systemId);
|
|
1466
|
-
|
|
1467
|
-
// Environments this system actually has runs for (env-less excluded -
|
|
1468
|
-
// it is folded into the rollup and never a real environment id).
|
|
1469
|
-
const envRows = await this.db
|
|
1470
|
-
.selectDistinct({ environmentId: healthCheckRuns.environmentId })
|
|
1471
|
-
.from(healthCheckRuns)
|
|
1472
|
-
.where(
|
|
1473
|
-
and(
|
|
1474
|
-
eq(healthCheckRuns.systemId, systemId),
|
|
1475
|
-
isNotNull(healthCheckRuns.environmentId),
|
|
1476
|
-
),
|
|
1477
|
-
);
|
|
1478
|
-
|
|
1479
|
-
const environments: Record<
|
|
1480
|
-
string,
|
|
1481
|
-
{
|
|
1482
|
-
status: HealthCheckStatus;
|
|
1483
|
-
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1484
|
-
}
|
|
1485
|
-
> = {};
|
|
1486
|
-
await Promise.all(
|
|
1487
|
-
envRows.map(async ({ environmentId }) => {
|
|
1488
|
-
if (!environmentId) return;
|
|
1489
|
-
const envStatus = await this.getSystemHealthStatus(
|
|
1490
|
-
systemId,
|
|
1491
|
-
environmentId,
|
|
1492
|
-
);
|
|
1493
|
-
environments[environmentId] = {
|
|
1494
|
-
status: envStatus.status,
|
|
1495
|
-
checkStatuses: envStatus.checkStatuses,
|
|
1496
|
-
};
|
|
1497
|
-
}),
|
|
1498
|
-
);
|
|
1499
|
-
|
|
1500
|
-
result[systemId] = {
|
|
1501
|
-
status: overall.status,
|
|
1502
|
-
checkStatuses: overall.checkStatuses,
|
|
1503
|
-
environments,
|
|
1504
|
-
};
|
|
1505
|
-
}),
|
|
1506
|
-
);
|
|
1507
|
-
|
|
1508
|
-
return result;
|
|
1507
|
+
async getSystemEnvironmentIds(systemId: string): Promise<string[]> {
|
|
1508
|
+
const envRows = await this.db
|
|
1509
|
+
.selectDistinct({ environmentId: healthCheckRuns.environmentId })
|
|
1510
|
+
.from(healthCheckRuns)
|
|
1511
|
+
.where(
|
|
1512
|
+
and(
|
|
1513
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1514
|
+
isNotNull(healthCheckRuns.environmentId),
|
|
1515
|
+
),
|
|
1516
|
+
);
|
|
1517
|
+
return envRows
|
|
1518
|
+
.map((r) => r.environmentId)
|
|
1519
|
+
.filter((id): id is string => id !== null);
|
|
1509
1520
|
}
|
|
1510
1521
|
|
|
1511
1522
|
/**
|
|
@@ -1513,193 +1524,237 @@ export class HealthCheckService {
|
|
|
1513
1524
|
* Returns all health checks with their last 25 runs for sparkline visualization.
|
|
1514
1525
|
*/
|
|
1515
1526
|
async getSystemHealthOverview(systemId: string) {
|
|
1516
|
-
//
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
.from(systemHealthChecks)
|
|
1528
|
-
.innerJoin(
|
|
1529
|
-
healthCheckConfigurations,
|
|
1530
|
-
eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
|
|
1531
|
-
)
|
|
1532
|
-
.where(eq(systemHealthChecks.systemId, systemId));
|
|
1533
|
-
|
|
1534
|
-
const checks = [];
|
|
1535
|
-
const sparklineLimit = 25;
|
|
1536
|
-
|
|
1537
|
-
for (const assoc of associations) {
|
|
1538
|
-
// Get last 25 runs for sparkline (newest first, then reverse for chronological display)
|
|
1539
|
-
const runs = await this.db
|
|
1540
|
-
.select({
|
|
1541
|
-
id: healthCheckRuns.id,
|
|
1542
|
-
status: healthCheckRuns.status,
|
|
1543
|
-
timestamp: healthCheckRuns.timestamp,
|
|
1544
|
-
environmentId: healthCheckRuns.environmentId,
|
|
1545
|
-
})
|
|
1546
|
-
.from(healthCheckRuns)
|
|
1547
|
-
.where(
|
|
1548
|
-
and(
|
|
1549
|
-
eq(healthCheckRuns.systemId, systemId),
|
|
1550
|
-
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1551
|
-
),
|
|
1552
|
-
)
|
|
1553
|
-
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1554
|
-
.limit(sparklineLimit);
|
|
1555
|
-
|
|
1556
|
-
// Reverse to chronological order (oldest first) for sparkline display
|
|
1557
|
-
const chronologicalRuns = runs.toReversed();
|
|
1558
|
-
|
|
1559
|
-
// Migrate and extract thresholds
|
|
1560
|
-
let thresholds: StateThresholds | undefined;
|
|
1561
|
-
if (assoc.stateThresholds) {
|
|
1562
|
-
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
// Most recent HEALTHY run per environment, computed OUTSIDE the bounded
|
|
1566
|
-
// sparkline window so "last successful run" stays correct even when a
|
|
1567
|
-
// check has been failing for far longer than the last 25 runs. One
|
|
1568
|
-
// grouped aggregate query per check (env-less = the `null` group). The
|
|
1569
|
-
// (system_id, configuration_id, environment_id, timestamp) index makes
|
|
1570
|
-
// this a cheap max-per-group scan.
|
|
1571
|
-
const lastHealthyRows = await this.db
|
|
1527
|
+
// §perf: batch the 1 (associations) + N·(2+E) (per-check recentRuns +
|
|
1528
|
+
// grouped last-healthy + per-env slice) reads into ONE scoped transaction
|
|
1529
|
+
// so the whole read fan-out pays a single BEGIN/SET LOCAL/COMMIT and holds
|
|
1530
|
+
// one connection, instead of 1+N·(2+E) standalone scoped queries each
|
|
1531
|
+
// checking a connection out. Only pure CPU (stateThresholds.parse,
|
|
1532
|
+
// evaluateHealthStatus, selectEffectiveEnvKeys) sits between the queries —
|
|
1533
|
+
// no DB-external await — so wrapping is safe. This mirrors the sibling
|
|
1534
|
+
// getSystemHealthStatus above. See withScopedTransaction.
|
|
1535
|
+
const checks = await withScopedTransaction(this.db, async (tx) => {
|
|
1536
|
+
// Get all associations with config details
|
|
1537
|
+
const associations = await tx
|
|
1572
1538
|
.select({
|
|
1573
|
-
|
|
1574
|
-
|
|
1539
|
+
configurationId: systemHealthChecks.configurationId,
|
|
1540
|
+
configName: healthCheckConfigurations.name,
|
|
1541
|
+
strategyId: healthCheckConfigurations.strategyId,
|
|
1542
|
+
intervalSeconds: healthCheckConfigurations.intervalSeconds,
|
|
1543
|
+
enabled: systemHealthChecks.enabled,
|
|
1544
|
+
paused: healthCheckConfigurations.paused,
|
|
1545
|
+
stateThresholds: systemHealthChecks.stateThresholds,
|
|
1546
|
+
// The per-assignment environment selector, surfaced so the check-level
|
|
1547
|
+
// rollup status here excludes slices whose env was disabled for this
|
|
1548
|
+
// assignment, and so the response can carry it to the frontend orphan
|
|
1549
|
+
// detection (a disabled-for-assignment env is tucked under "Old checks"
|
|
1550
|
+
// even though it is still part of the system's membership).
|
|
1551
|
+
environmentIds: systemHealthChecks.environmentIds,
|
|
1575
1552
|
})
|
|
1576
|
-
.from(
|
|
1577
|
-
.
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1581
|
-
eq(healthCheckRuns.status, "healthy"),
|
|
1582
|
-
),
|
|
1553
|
+
.from(systemHealthChecks)
|
|
1554
|
+
.innerJoin(
|
|
1555
|
+
healthCheckConfigurations,
|
|
1556
|
+
eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
|
|
1583
1557
|
)
|
|
1584
|
-
.
|
|
1585
|
-
const lastHealthyByEnv = new Map<string | null, Date>();
|
|
1586
|
-
let checkLastSuccessfulRunAt: Date | undefined;
|
|
1587
|
-
for (const row of lastHealthyRows) {
|
|
1588
|
-
if (!row.lastSuccessAt) continue;
|
|
1589
|
-
lastHealthyByEnv.set(row.environmentId ?? null, row.lastSuccessAt);
|
|
1590
|
-
if (
|
|
1591
|
-
!checkLastSuccessfulRunAt ||
|
|
1592
|
-
row.lastSuccessAt > checkLastSuccessfulRunAt
|
|
1593
|
-
) {
|
|
1594
|
-
checkLastSuccessfulRunAt = row.lastSuccessAt;
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1558
|
+
.where(eq(systemHealthChecks.systemId, systemId));
|
|
1597
1559
|
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
// monotonic run window and worst-wins across envs — this is the same
|
|
1601
|
-
// derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
|
|
1602
|
-
// that method for the rationale (flattening envs feeds interleaved
|
|
1603
|
-
// statuses to the consecutive evaluator and masks sibling outages).
|
|
1604
|
-
const perEnvironment: {
|
|
1605
|
-
environmentId: string | null;
|
|
1606
|
-
status: HealthCheckStatus;
|
|
1607
|
-
lastSuccessfulRunAt?: Date;
|
|
1608
|
-
recentRuns: { id: string; status: HealthCheckStatus; timestamp: Date }[];
|
|
1609
|
-
}[] = [];
|
|
1610
|
-
|
|
1611
|
-
// Stable ordering of env keys: env-less (`null`) first, then env ids in
|
|
1612
|
-
// the order they were first encountered in the mixed pool (membership
|
|
1613
|
-
// order is otherwise unobservable here without a catalog read; recent
|
|
1614
|
-
// runs surface stable, recent order).
|
|
1615
|
-
const envKeys: (string | null)[] = [];
|
|
1616
|
-
const seenEnv = new Set<string | null>();
|
|
1617
|
-
for (const r of runs) {
|
|
1618
|
-
const key = r.environmentId ?? null;
|
|
1619
|
-
if (!seenEnv.has(key)) {
|
|
1620
|
-
seenEnv.add(key);
|
|
1621
|
-
envKeys.push(key);
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
// If no runs at all, surface a single env-less entry so UI can render
|
|
1625
|
-
// an empty row rather than nothing.
|
|
1626
|
-
if (envKeys.length === 0) envKeys.push(null);
|
|
1560
|
+
const checks = [];
|
|
1561
|
+
const sparklineLimit = 25;
|
|
1627
1562
|
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
const
|
|
1563
|
+
for (const assoc of associations) {
|
|
1564
|
+
// Get last 25 runs for sparkline (newest first, then reverse for chronological display)
|
|
1565
|
+
const runs = await tx
|
|
1631
1566
|
.select({
|
|
1632
1567
|
id: healthCheckRuns.id,
|
|
1633
1568
|
status: healthCheckRuns.status,
|
|
1634
1569
|
timestamp: healthCheckRuns.timestamp,
|
|
1570
|
+
environmentId: healthCheckRuns.environmentId,
|
|
1635
1571
|
})
|
|
1636
1572
|
.from(healthCheckRuns)
|
|
1637
1573
|
.where(
|
|
1638
1574
|
and(
|
|
1639
1575
|
eq(healthCheckRuns.systemId, systemId),
|
|
1640
1576
|
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1641
|
-
envId === null
|
|
1642
|
-
? isNull(healthCheckRuns.environmentId)
|
|
1643
|
-
: eq(healthCheckRuns.environmentId, envId),
|
|
1644
1577
|
),
|
|
1645
1578
|
)
|
|
1646
1579
|
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1647
1580
|
.limit(sparklineLimit);
|
|
1648
1581
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1582
|
+
// Reverse to chronological order (oldest first) for sparkline display
|
|
1583
|
+
const chronologicalRuns = runs.toReversed();
|
|
1584
|
+
|
|
1585
|
+
// Migrate and extract thresholds
|
|
1586
|
+
let thresholds: StateThresholds | undefined;
|
|
1587
|
+
if (assoc.stateThresholds) {
|
|
1588
|
+
thresholds = await stateThresholds.parse(assoc.stateThresholds);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// Most recent HEALTHY run per environment, computed OUTSIDE the bounded
|
|
1592
|
+
// sparkline window so "last successful run" stays correct even when a
|
|
1593
|
+
// check has been failing for far longer than the last 25 runs. One
|
|
1594
|
+
// grouped aggregate query per check (env-less = the `null` group).
|
|
1595
|
+
// `health_check_runs_slice_recent_idx` (system_id, configuration_id,
|
|
1596
|
+
// environment_id, timestamp) makes this a cheap max-per-group scan.
|
|
1597
|
+
const lastHealthyRows = await tx
|
|
1598
|
+
.select({
|
|
1599
|
+
environmentId: healthCheckRuns.environmentId,
|
|
1600
|
+
lastSuccessAt: max(healthCheckRuns.timestamp),
|
|
1601
|
+
})
|
|
1602
|
+
.from(healthCheckRuns)
|
|
1603
|
+
.where(
|
|
1604
|
+
and(
|
|
1605
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1606
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1607
|
+
eq(healthCheckRuns.status, "healthy"),
|
|
1608
|
+
),
|
|
1609
|
+
)
|
|
1610
|
+
.groupBy(healthCheckRuns.environmentId);
|
|
1611
|
+
const lastHealthyByEnv = new Map<string | null, Date>();
|
|
1612
|
+
let checkLastSuccessfulRunAt: Date | undefined;
|
|
1613
|
+
for (const row of lastHealthyRows) {
|
|
1614
|
+
if (!row.lastSuccessAt) continue;
|
|
1615
|
+
lastHealthyByEnv.set(row.environmentId ?? null, row.lastSuccessAt);
|
|
1616
|
+
if (
|
|
1617
|
+
!checkLastSuccessfulRunAt ||
|
|
1618
|
+
row.lastSuccessAt > checkLastSuccessfulRunAt
|
|
1619
|
+
) {
|
|
1620
|
+
checkLastSuccessfulRunAt = row.lastSuccessAt;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Group the fetched runs by environmentId (null = env-less slice). We
|
|
1625
|
+
// query each env's slice separately below to evaluate it on its own
|
|
1626
|
+
// monotonic run window and worst-wins across envs — this is the same
|
|
1627
|
+
// derivation `getSystemHealthStatus(systemId)` uses for the rollup; see
|
|
1628
|
+
// that method for the rationale (flattening envs feeds interleaved
|
|
1629
|
+
// statuses to the consecutive evaluator and masks sibling outages).
|
|
1630
|
+
const perEnvironment: {
|
|
1631
|
+
environmentId: string | null;
|
|
1632
|
+
status: HealthCheckStatus;
|
|
1633
|
+
lastSuccessfulRunAt?: Date;
|
|
1634
|
+
recentRuns: {
|
|
1635
|
+
id: string;
|
|
1636
|
+
status: HealthCheckStatus;
|
|
1637
|
+
timestamp: Date;
|
|
1638
|
+
}[];
|
|
1639
|
+
}[] = [];
|
|
1640
|
+
|
|
1641
|
+
// Stable ordering of env keys: env-less (`null`) first, then env ids in
|
|
1642
|
+
// the order they were first encountered in the mixed pool (membership
|
|
1643
|
+
// order is otherwise unobservable here without a catalog read; recent
|
|
1644
|
+
// runs surface stable, recent order).
|
|
1645
|
+
const envKeys: (string | null)[] = [];
|
|
1646
|
+
const seenEnv = new Set<string | null>();
|
|
1647
|
+
for (const r of runs) {
|
|
1648
|
+
const key = r.environmentId ?? null;
|
|
1649
|
+
if (!seenEnv.has(key)) {
|
|
1650
|
+
seenEnv.add(key);
|
|
1651
|
+
envKeys.push(key);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
// If no runs at all, surface a single env-less entry so UI can render
|
|
1655
|
+
// an empty row rather than nothing.
|
|
1656
|
+
if (envKeys.length === 0) envKeys.push(null);
|
|
1657
|
+
|
|
1658
|
+
// The slices that currently CONTRIBUTE to this check's rollup status: a
|
|
1659
|
+
// concrete env removed from `environmentIds` (disabled for the assignment)
|
|
1660
|
+
// and the stale env-less slice of a check that now fans out are excluded,
|
|
1661
|
+
// mirroring `getSystemHealthStatus`. The orphaned slices are still emitted
|
|
1662
|
+
// in `perEnvironment` (the frontend tucks them under "Old checks"); they
|
|
1663
|
+
// just no longer drag the check-level worst-wins `status`.
|
|
1664
|
+
const effectiveKeys = selectEffectiveEnvKeys({
|
|
1665
|
+
environmentIds: assoc.environmentIds,
|
|
1666
|
+
presentEnvKeys: envKeys,
|
|
1652
1667
|
});
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1668
|
+
|
|
1669
|
+
let aggregateStatus: HealthCheckStatus = "healthy";
|
|
1670
|
+
for (const envId of envKeys) {
|
|
1671
|
+
const envRuns = await tx
|
|
1672
|
+
.select({
|
|
1673
|
+
id: healthCheckRuns.id,
|
|
1674
|
+
status: healthCheckRuns.status,
|
|
1675
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1676
|
+
})
|
|
1677
|
+
.from(healthCheckRuns)
|
|
1678
|
+
.where(
|
|
1679
|
+
and(
|
|
1680
|
+
eq(healthCheckRuns.systemId, systemId),
|
|
1681
|
+
eq(healthCheckRuns.configurationId, assoc.configurationId),
|
|
1682
|
+
envId === null
|
|
1683
|
+
? isNull(healthCheckRuns.environmentId)
|
|
1684
|
+
: eq(healthCheckRuns.environmentId, envId),
|
|
1685
|
+
),
|
|
1686
|
+
)
|
|
1687
|
+
.orderBy(desc(healthCheckRuns.timestamp))
|
|
1688
|
+
.limit(sparklineLimit);
|
|
1689
|
+
|
|
1690
|
+
const envStatus = evaluateHealthStatus({
|
|
1691
|
+
runs: envRuns,
|
|
1692
|
+
thresholds,
|
|
1693
|
+
});
|
|
1694
|
+
// Worst-wins across EFFECTIVE envs (unhealthy > degraded > healthy).
|
|
1695
|
+
// An orphaned slice's status is still reported per-env but must not
|
|
1696
|
+
// move the aggregate.
|
|
1697
|
+
if (effectiveKeys.has(envId)) {
|
|
1698
|
+
if (envStatus === "unhealthy") {
|
|
1699
|
+
aggregateStatus = "unhealthy";
|
|
1700
|
+
} else if (
|
|
1701
|
+
envStatus === "degraded" &&
|
|
1702
|
+
aggregateStatus === "healthy"
|
|
1703
|
+
) {
|
|
1704
|
+
aggregateStatus = "degraded";
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
perEnvironment.push({
|
|
1709
|
+
environmentId: envId,
|
|
1710
|
+
status: envStatus,
|
|
1711
|
+
lastSuccessfulRunAt: lastHealthyByEnv.get(envId),
|
|
1712
|
+
recentRuns: envRuns.toReversed().map((r) => ({
|
|
1713
|
+
id: r.id,
|
|
1714
|
+
status: r.status,
|
|
1715
|
+
timestamp: r.timestamp,
|
|
1716
|
+
})),
|
|
1717
|
+
});
|
|
1658
1718
|
}
|
|
1659
1719
|
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1720
|
+
// Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects).
|
|
1721
|
+
// For a paused configuration the runs are stale (execution is skipped),
|
|
1722
|
+
// so the evaluated `status` is NOT a meaningful current verdict — the
|
|
1723
|
+
// frontend renders a "Paused" pill from the `paused` flag instead.
|
|
1724
|
+
// We still compute it so the historical/sparkline path stays uniform,
|
|
1725
|
+
// and so a non-paused consumer that ignores `paused` sees a best-
|
|
1726
|
+
// effort status rather than a hard null. `aggregateStatus` is the
|
|
1727
|
+
// worst-wins-across-envs rollup derived above (it equals what
|
|
1728
|
+
// evaluateHealthStatus would return on the flat pool if only ONE env is
|
|
1729
|
+
// present, preserving per-check single-env behavior).
|
|
1730
|
+
const status = aggregateStatus;
|
|
1731
|
+
|
|
1732
|
+
checks.push({
|
|
1733
|
+
configurationId: assoc.configurationId,
|
|
1734
|
+
configurationName: assoc.configName,
|
|
1735
|
+
strategyId: assoc.strategyId,
|
|
1736
|
+
intervalSeconds: assoc.intervalSeconds,
|
|
1737
|
+
enabled: assoc.enabled,
|
|
1738
|
+
paused: assoc.paused,
|
|
1739
|
+
status,
|
|
1740
|
+
stateThresholds: thresholds,
|
|
1741
|
+
// Surface the per-assignment environment selector so the frontend can
|
|
1742
|
+
// treat a slice whose env was disabled for THIS assignment as orphaned
|
|
1743
|
+
// (system membership alone can't tell - the env is still in the system).
|
|
1744
|
+
environmentIds: assoc.environmentIds,
|
|
1745
|
+
lastSuccessfulRunAt: checkLastSuccessfulRunAt,
|
|
1746
|
+
recentRuns: chronologicalRuns.map((r) => ({
|
|
1665
1747
|
id: r.id,
|
|
1666
1748
|
status: r.status,
|
|
1667
1749
|
timestamp: r.timestamp,
|
|
1750
|
+
environmentId: r.environmentId,
|
|
1668
1751
|
})),
|
|
1752
|
+
perEnvironment,
|
|
1669
1753
|
});
|
|
1670
1754
|
}
|
|
1671
1755
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
// so the evaluated `status` is NOT a meaningful current verdict — the
|
|
1675
|
-
// frontend renders a "Paused" pill from the `paused` flag instead.
|
|
1676
|
-
// We still compute it so the historical/sparkline path stays uniform,
|
|
1677
|
-
// and so a non-paused consumer that ignores `paused` sees a best-
|
|
1678
|
-
// effort status rather than a hard null. `aggregateStatus` is the
|
|
1679
|
-
// worst-wins-across-envs rollup derived above (it equals what
|
|
1680
|
-
// evaluateHealthStatus would return on the flat pool if only ONE env is
|
|
1681
|
-
// present, preserving per-check single-env behavior).
|
|
1682
|
-
const status = aggregateStatus;
|
|
1683
|
-
|
|
1684
|
-
checks.push({
|
|
1685
|
-
configurationId: assoc.configurationId,
|
|
1686
|
-
configurationName: assoc.configName,
|
|
1687
|
-
strategyId: assoc.strategyId,
|
|
1688
|
-
intervalSeconds: assoc.intervalSeconds,
|
|
1689
|
-
enabled: assoc.enabled,
|
|
1690
|
-
paused: assoc.paused,
|
|
1691
|
-
status,
|
|
1692
|
-
stateThresholds: thresholds,
|
|
1693
|
-
lastSuccessfulRunAt: checkLastSuccessfulRunAt,
|
|
1694
|
-
recentRuns: chronologicalRuns.map((r) => ({
|
|
1695
|
-
id: r.id,
|
|
1696
|
-
status: r.status,
|
|
1697
|
-
timestamp: r.timestamp,
|
|
1698
|
-
environmentId: r.environmentId,
|
|
1699
|
-
})),
|
|
1700
|
-
perEnvironment,
|
|
1701
|
-
});
|
|
1702
|
-
}
|
|
1756
|
+
return checks;
|
|
1757
|
+
});
|
|
1703
1758
|
|
|
1704
1759
|
return { systemId, checks };
|
|
1705
1760
|
}
|
|
@@ -1814,6 +1869,7 @@ export class HealthCheckService {
|
|
|
1814
1869
|
sourceFilter?: string;
|
|
1815
1870
|
statusFilter?: HealthCheckStatus[];
|
|
1816
1871
|
environmentId?: string | null;
|
|
1872
|
+
environmentIds?: string[];
|
|
1817
1873
|
maxBuckets?: number;
|
|
1818
1874
|
}): Promise<RunStats> {
|
|
1819
1875
|
const {
|
|
@@ -1824,6 +1880,7 @@ export class HealthCheckService {
|
|
|
1824
1880
|
sourceFilter,
|
|
1825
1881
|
statusFilter,
|
|
1826
1882
|
environmentId,
|
|
1883
|
+
environmentIds,
|
|
1827
1884
|
maxBuckets = 24,
|
|
1828
1885
|
} = props;
|
|
1829
1886
|
|
|
@@ -1848,6 +1905,12 @@ export class HealthCheckService {
|
|
|
1848
1905
|
} else if (environmentId !== undefined) {
|
|
1849
1906
|
conditions.push(eq(healthCheckRuns.environmentId, environmentId));
|
|
1850
1907
|
}
|
|
1908
|
+
// Set-of-environments filter (the status page's per-page env scope). Only
|
|
1909
|
+
// counts runs tagged with one of the selected envs; env-less runs are
|
|
1910
|
+
// excluded (they belong to no published environment).
|
|
1911
|
+
if (environmentIds && environmentIds.length > 0) {
|
|
1912
|
+
conditions.push(inArray(healthCheckRuns.environmentId, environmentIds));
|
|
1913
|
+
}
|
|
1851
1914
|
|
|
1852
1915
|
const rows = await this.db
|
|
1853
1916
|
.select({
|
|
@@ -1867,6 +1930,67 @@ export class HealthCheckService {
|
|
|
1867
1930
|
return summarizeRuns({ runs, startDate, endDate, maxBuckets });
|
|
1868
1931
|
}
|
|
1869
1932
|
|
|
1933
|
+
/**
|
|
1934
|
+
* Bulk variant of {@link getRunStats}: compute the compact stats summary for
|
|
1935
|
+
* MANY systems over one shared window in a SINGLE grouped read, instead of an
|
|
1936
|
+
* N+1 fan-out of per-system `getRunStats` calls. Fetches every matching run
|
|
1937
|
+
* for all `systemIds` with one `inArray` query, groups by systemId in JS, and
|
|
1938
|
+
* runs the same pure `summarizeRuns` per system so each entry is identical to
|
|
1939
|
+
* what `getRunStats({ systemId })` would return. Systems with no runs in the
|
|
1940
|
+
* window are OMITTED from the record (they simply have no rows to group),
|
|
1941
|
+
* matching the single endpoint's "no runs => nothing to report" semantics.
|
|
1942
|
+
*/
|
|
1943
|
+
async getBulkRunStats(props: {
|
|
1944
|
+
systemIds: string[];
|
|
1945
|
+
startDate: Date;
|
|
1946
|
+
endDate: Date;
|
|
1947
|
+
environmentIds?: string[];
|
|
1948
|
+
maxBuckets?: number;
|
|
1949
|
+
}): Promise<Record<string, RunStats>> {
|
|
1950
|
+
const { systemIds, startDate, endDate, environmentIds, maxBuckets = 24 } =
|
|
1951
|
+
props;
|
|
1952
|
+
if (systemIds.length === 0) return {};
|
|
1953
|
+
|
|
1954
|
+
const conditions = [
|
|
1955
|
+
inArray(healthCheckRuns.systemId, systemIds),
|
|
1956
|
+
gte(healthCheckRuns.timestamp, startDate),
|
|
1957
|
+
lte(healthCheckRuns.timestamp, endDate),
|
|
1958
|
+
];
|
|
1959
|
+
// Set-of-environments filter (the status page's per-page env scope): count
|
|
1960
|
+
// only runs tagged with one of the selected envs; env-less runs excluded.
|
|
1961
|
+
if (environmentIds && environmentIds.length > 0) {
|
|
1962
|
+
conditions.push(inArray(healthCheckRuns.environmentId, environmentIds));
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
const rows = await this.db
|
|
1966
|
+
.select({
|
|
1967
|
+
systemId: healthCheckRuns.systemId,
|
|
1968
|
+
timestamp: healthCheckRuns.timestamp,
|
|
1969
|
+
status: healthCheckRuns.status,
|
|
1970
|
+
latencyMs: healthCheckRuns.latencyMs,
|
|
1971
|
+
})
|
|
1972
|
+
.from(healthCheckRuns)
|
|
1973
|
+
.where(and(...conditions));
|
|
1974
|
+
|
|
1975
|
+
const bySystem = new Map<string, StatRun[]>();
|
|
1976
|
+
for (const r of rows) {
|
|
1977
|
+
const list = bySystem.get(r.systemId);
|
|
1978
|
+
const run: StatRun = {
|
|
1979
|
+
timestamp: r.timestamp,
|
|
1980
|
+
status: r.status,
|
|
1981
|
+
latencyMs: r.latencyMs ?? undefined,
|
|
1982
|
+
};
|
|
1983
|
+
if (list) list.push(run);
|
|
1984
|
+
else bySystem.set(r.systemId, [run]);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
const out: Record<string, RunStats> = {};
|
|
1988
|
+
for (const [systemId, runs] of bySystem) {
|
|
1989
|
+
out[systemId] = summarizeRuns({ runs, startDate, endDate, maxBuckets });
|
|
1990
|
+
}
|
|
1991
|
+
return out;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1870
1994
|
/**
|
|
1871
1995
|
* Get detailed health check run history with full result data.
|
|
1872
1996
|
* Restricted to users with manage access.
|