@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/drizzle/0019_chemical_frightful_four.sql +8 -0
  3. package/drizzle/0020_certain_mordo.sql +2 -0
  4. package/drizzle/meta/0019_snapshot.json +661 -0
  5. package/drizzle/meta/0020_snapshot.json +711 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +23 -21
  8. package/src/ai/system-signals-contributor.test.ts +33 -9
  9. package/src/ai/system-signals-contributor.ts +38 -16
  10. package/src/cache-test-stub.ts +26 -0
  11. package/src/cache.test.ts +291 -0
  12. package/src/cache.ts +204 -34
  13. package/src/health-notification-content.test.ts +111 -0
  14. package/src/health-notification-content.ts +145 -0
  15. package/src/healthcheck-gitops-kinds.test.ts +14 -0
  16. package/src/healthcheck-gitops-kinds.ts +27 -0
  17. package/src/index.ts +31 -12
  18. package/src/queue-executor.test.ts +13 -26
  19. package/src/queue-executor.ts +125 -112
  20. package/src/retention-job.ts +8 -0
  21. package/src/rollup-consumer.test.ts +19 -8
  22. package/src/router-config-secrets.test.ts +2 -7
  23. package/src/router-create-and-assign.test.ts +2 -7
  24. package/src/router-pause-recompute.test.ts +2 -7
  25. package/src/router.test.ts +3 -8
  26. package/src/router.ts +43 -15
  27. package/src/schema.ts +74 -31
  28. package/src/service-batching.test.ts +8 -0
  29. package/src/service-bulk-counts.it.test.ts +144 -0
  30. package/src/service-bulk-run-stats.it.test.ts +197 -0
  31. package/src/service-ordering.test.ts +6 -2
  32. package/src/service-paused-filter.test.ts +13 -0
  33. package/src/service-rollup-worst-wins.test.ts +209 -145
  34. package/src/service.ts +408 -284
  35. package/src/status-fingerprint.test.ts +92 -0
  36. package/src/status-fingerprint.ts +66 -0
  37. package/src/status-page/rollup.test.ts +40 -0
  38. package/src/status-page/rollup.ts +27 -0
  39. package/src/status-page/widgets.test.ts +387 -0
  40. 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. The lookup index leads with
1129
- // (system_id, environment_id, …) so the env-scoped query is index-efficient.
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
- const runs = await tx
1171
- .select({
1172
- status: healthCheckRuns.status,
1173
- timestamp: healthCheckRuns.timestamp,
1174
- environmentId: healthCheckRuns.environmentId,
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
- .orderBy(desc(healthCheckRuns.timestamp))
1184
- .limit(maxWindowSize);
1228
+ );
1229
+ const presentEnvKeys = distinctEnvRows.map(
1230
+ (r) => r.environmentId ?? null,
1231
+ );
1185
1232
 
1186
- // Group by environmentId. `null` is its own group (the env-less slice
1187
- // of an assignment that has opted out, plus any pre-3b env-less runs).
1188
- const byEnv = new Map<string | null, { status: HealthCheckStatus; timestamp: Date }[]>();
1189
- for (const r of runs) {
1190
- const key = r.environmentId ?? null;
1191
- const bucket = byEnv.get(key);
1192
- if (bucket) {
1193
- bucket.push(r);
1194
- } else {
1195
- byEnv.set(key, [r]);
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 = runs.length;
1201
- lastRunAt = runs[0]?.timestamp;
1202
- // Each env group is a slice. A check that has runs against N envs
1203
- // currently fans out to N; before it has ever run it is still one
1204
- // logical slice (byEnv empty => keep the default 1).
1205
- sliceCount = Math.max(byEnv.size, 1);
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 envRuns of byEnv.values()) {
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
- // Distinct systemIds that have at least one ENABLED check association.
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
- rows.map(async ({ systemId }) => {
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
- * Bulk per-(system, check, environment) health for the given systems.
1421
- *
1422
- * For each system returns the cross-environment rollup (status +
1423
- * checkStatuses, same as {@link getSystemHealthStatus}) PLUS a slice per
1424
- * environment the system has runs for. Consumers that scope by environment
1425
- * (the dependency map) must read the per-environment slice, because the
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 getBulkSystemHealthMatrix(systemIds: string[]): Promise<
1433
- Record<
1434
- string,
1435
- {
1436
- status: HealthCheckStatus;
1437
- checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1438
- environments: Record<
1439
- string,
1440
- {
1441
- status: HealthCheckStatus;
1442
- checkStatuses: SystemHealthStatusResponse["checkStatuses"];
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
- // Get all associations with config details
1517
- const associations = await this.db
1518
- .select({
1519
- configurationId: systemHealthChecks.configurationId,
1520
- configName: healthCheckConfigurations.name,
1521
- strategyId: healthCheckConfigurations.strategyId,
1522
- intervalSeconds: healthCheckConfigurations.intervalSeconds,
1523
- enabled: systemHealthChecks.enabled,
1524
- paused: healthCheckConfigurations.paused,
1525
- stateThresholds: systemHealthChecks.stateThresholds,
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
- environmentId: healthCheckRuns.environmentId,
1574
- lastSuccessAt: max(healthCheckRuns.timestamp),
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(healthCheckRuns)
1577
- .where(
1578
- and(
1579
- eq(healthCheckRuns.systemId, systemId),
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
- .groupBy(healthCheckRuns.environmentId);
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
- // Group the fetched runs by environmentId (null = env-less slice). We
1599
- // query each env's slice separately below to evaluate it on its own
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
- let aggregateStatus: HealthCheckStatus = "healthy";
1629
- for (const envId of envKeys) {
1630
- const envRuns = await this.db
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
- const envStatus = evaluateHealthStatus({
1650
- runs: envRuns,
1651
- thresholds,
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
- // Worst-wins across envs (unhealthy > degraded > healthy).
1654
- if (envStatus === "unhealthy") {
1655
- aggregateStatus = "unhealthy";
1656
- } else if (envStatus === "degraded" && aggregateStatus === "healthy") {
1657
- aggregateStatus = "degraded";
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
- perEnvironment.push({
1661
- environmentId: envId,
1662
- status: envStatus,
1663
- lastSuccessfulRunAt: lastHealthyByEnv.get(envId),
1664
- recentRuns: envRuns.toReversed().map((r) => ({
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
- // Evaluate current status (runs are in DESC order - newest first - as evaluateHealthStatus expects).
1673
- // For a paused configuration the runs are stale (execution is skipped),
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.