@checkstack/healthcheck-backend 1.19.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 +190 -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 +21 -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 +22 -0
- package/src/health-notification-content.ts +7 -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 +94 -44
- 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 +7 -15
- package/src/schema.ts +74 -31
- package/src/service.ts +45 -102
- package/src/status-fingerprint.test.ts +92 -0
- package/src/status-fingerprint.ts +66 -0
- package/src/status-page/widgets.test.ts +84 -0
- package/src/status-page/widgets.ts +87 -6
package/src/service.ts
CHANGED
|
@@ -1166,8 +1166,9 @@ export class HealthCheckService {
|
|
|
1166
1166
|
|
|
1167
1167
|
// Environment filter for the per-check run window. `undefined` (rollup)
|
|
1168
1168
|
// adds no predicate; `null` filters to the env-less slice; a string
|
|
1169
|
-
// filters to that environment.
|
|
1170
|
-
// (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.
|
|
1171
1172
|
//
|
|
1172
1173
|
// For the rollup, we deliberately do NOT apply a single envFilter to one
|
|
1173
1174
|
// flat run list — see the per-association branch below for why.
|
|
@@ -1357,17 +1358,10 @@ export class HealthCheckService {
|
|
|
1357
1358
|
* list and no process-local state.
|
|
1358
1359
|
*/
|
|
1359
1360
|
async getAllUnhealthySystemStatuses(): Promise<HealthcheckSignalStatuses> {
|
|
1360
|
-
|
|
1361
|
-
// `getSystemHealthStatus` already short-circuits to healthy for systems
|
|
1362
|
-
// with no enabled associations, so this is the complete candidate set.
|
|
1363
|
-
const rows = await this.db
|
|
1364
|
-
.selectDistinct({ systemId: systemHealthChecks.systemId })
|
|
1365
|
-
.from(systemHealthChecks)
|
|
1366
|
-
.where(eq(systemHealthChecks.enabled, true));
|
|
1367
|
-
|
|
1361
|
+
const systemIds = await this.getUnhealthyCandidateSystemIds();
|
|
1368
1362
|
const result: HealthcheckSignalStatuses = {};
|
|
1369
1363
|
await Promise.all(
|
|
1370
|
-
|
|
1364
|
+
systemIds.map(async (systemId) => {
|
|
1371
1365
|
const status = await this.getSystemHealthStatus(systemId);
|
|
1372
1366
|
if (status.status === "healthy") return; // problems only
|
|
1373
1367
|
result[systemId] = status;
|
|
@@ -1376,6 +1370,24 @@ export class HealthCheckService {
|
|
|
1376
1370
|
return result;
|
|
1377
1371
|
}
|
|
1378
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
|
+
|
|
1379
1391
|
/**
|
|
1380
1392
|
* Live health-state snapshot for a single system (Wave-2 sensing
|
|
1381
1393
|
* contract). When `configurationId` is given, status reflects that
|
|
@@ -1485,95 +1497,26 @@ export class HealthCheckService {
|
|
|
1485
1497
|
}
|
|
1486
1498
|
|
|
1487
1499
|
/**
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
*
|
|
1491
|
-
*
|
|
1492
|
-
*
|
|
1493
|
-
*
|
|
1494
|
-
* rollup deliberately hides a single failing environment.
|
|
1495
|
-
*
|
|
1496
|
-
* Cost scales with the number of environments each system actually fans out
|
|
1497
|
-
* to (`1 + #envs` status evaluations per system); systems with only env-less
|
|
1498
|
-
* 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.
|
|
1499
1506
|
*/
|
|
1500
|
-
async
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
}
|
|
1514
|
-
>
|
|
1515
|
-
> {
|
|
1516
|
-
const result: Record<
|
|
1517
|
-
string,
|
|
1518
|
-
{
|
|
1519
|
-
status: HealthCheckStatus;
|
|
1520
|
-
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1521
|
-
environments: Record<
|
|
1522
|
-
string,
|
|
1523
|
-
{
|
|
1524
|
-
status: HealthCheckStatus;
|
|
1525
|
-
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1526
|
-
}
|
|
1527
|
-
>;
|
|
1528
|
-
}
|
|
1529
|
-
> = {};
|
|
1530
|
-
|
|
1531
|
-
await Promise.all(
|
|
1532
|
-
systemIds.map(async (systemId) => {
|
|
1533
|
-
const overall = await this.getSystemHealthStatus(systemId);
|
|
1534
|
-
|
|
1535
|
-
// Environments this system actually has runs for (env-less excluded -
|
|
1536
|
-
// it is folded into the rollup and never a real environment id).
|
|
1537
|
-
const envRows = await this.db
|
|
1538
|
-
.selectDistinct({ environmentId: healthCheckRuns.environmentId })
|
|
1539
|
-
.from(healthCheckRuns)
|
|
1540
|
-
.where(
|
|
1541
|
-
and(
|
|
1542
|
-
eq(healthCheckRuns.systemId, systemId),
|
|
1543
|
-
isNotNull(healthCheckRuns.environmentId),
|
|
1544
|
-
),
|
|
1545
|
-
);
|
|
1546
|
-
|
|
1547
|
-
const environments: Record<
|
|
1548
|
-
string,
|
|
1549
|
-
{
|
|
1550
|
-
status: HealthCheckStatus;
|
|
1551
|
-
checkStatuses: SystemHealthStatusResponse["checkStatuses"];
|
|
1552
|
-
}
|
|
1553
|
-
> = {};
|
|
1554
|
-
await Promise.all(
|
|
1555
|
-
envRows.map(async ({ environmentId }) => {
|
|
1556
|
-
if (!environmentId) return;
|
|
1557
|
-
const envStatus = await this.getSystemHealthStatus(
|
|
1558
|
-
systemId,
|
|
1559
|
-
environmentId,
|
|
1560
|
-
);
|
|
1561
|
-
environments[environmentId] = {
|
|
1562
|
-
status: envStatus.status,
|
|
1563
|
-
checkStatuses: envStatus.checkStatuses,
|
|
1564
|
-
};
|
|
1565
|
-
}),
|
|
1566
|
-
);
|
|
1567
|
-
|
|
1568
|
-
result[systemId] = {
|
|
1569
|
-
status: overall.status,
|
|
1570
|
-
checkStatuses: overall.checkStatuses,
|
|
1571
|
-
environments,
|
|
1572
|
-
};
|
|
1573
|
-
}),
|
|
1574
|
-
);
|
|
1575
|
-
|
|
1576
|
-
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);
|
|
1577
1520
|
}
|
|
1578
1521
|
|
|
1579
1522
|
/**
|
|
@@ -1648,9 +1591,9 @@ export class HealthCheckService {
|
|
|
1648
1591
|
// Most recent HEALTHY run per environment, computed OUTSIDE the bounded
|
|
1649
1592
|
// sparkline window so "last successful run" stays correct even when a
|
|
1650
1593
|
// check has been failing for far longer than the last 25 runs. One
|
|
1651
|
-
// grouped aggregate query per check (env-less = the `null` group).
|
|
1652
|
-
// (system_id, configuration_id,
|
|
1653
|
-
// this a cheap max-per-group scan.
|
|
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.
|
|
1654
1597
|
const lastHealthyRows = await tx
|
|
1655
1598
|
.select({
|
|
1656
1599
|
environmentId: healthCheckRuns.environmentId,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
statusFingerprint,
|
|
4
|
+
statusVectorChanged,
|
|
5
|
+
type StatusFingerprintInput,
|
|
6
|
+
} from "./status-fingerprint";
|
|
7
|
+
|
|
8
|
+
const check = (
|
|
9
|
+
configurationId: string,
|
|
10
|
+
status: string,
|
|
11
|
+
sliceCount = 1,
|
|
12
|
+
failingSliceCount = 0,
|
|
13
|
+
): StatusFingerprintInput["checkStatuses"][number] => ({
|
|
14
|
+
configurationId,
|
|
15
|
+
status,
|
|
16
|
+
sliceCount,
|
|
17
|
+
failingSliceCount,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("statusFingerprint", () => {
|
|
21
|
+
it("is invariant to check ORDER", () => {
|
|
22
|
+
const a: StatusFingerprintInput = {
|
|
23
|
+
status: "degraded",
|
|
24
|
+
checkStatuses: [check("c1", "healthy"), check("c2", "degraded")],
|
|
25
|
+
};
|
|
26
|
+
const b: StatusFingerprintInput = {
|
|
27
|
+
status: "degraded",
|
|
28
|
+
checkStatuses: [check("c2", "degraded"), check("c1", "healthy")],
|
|
29
|
+
};
|
|
30
|
+
expect(statusFingerprint(a)).toBe(statusFingerprint(b));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("differs when a check's status flips", () => {
|
|
34
|
+
const before = statusFingerprint({
|
|
35
|
+
status: "healthy",
|
|
36
|
+
checkStatuses: [check("c1", "healthy")],
|
|
37
|
+
});
|
|
38
|
+
const after = statusFingerprint({
|
|
39
|
+
status: "healthy",
|
|
40
|
+
checkStatuses: [check("c1", "degraded")],
|
|
41
|
+
});
|
|
42
|
+
expect(before).not.toBe(after);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("differs when only the slice failure count changes", () => {
|
|
46
|
+
const before = statusFingerprint({
|
|
47
|
+
status: "degraded",
|
|
48
|
+
checkStatuses: [check("c1", "degraded", 3, 1)],
|
|
49
|
+
});
|
|
50
|
+
const after = statusFingerprint({
|
|
51
|
+
status: "degraded",
|
|
52
|
+
checkStatuses: [check("c1", "degraded", 3, 2)],
|
|
53
|
+
});
|
|
54
|
+
expect(before).not.toBe(after);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("statusVectorChanged", () => {
|
|
59
|
+
const base: StatusFingerprintInput = {
|
|
60
|
+
status: "healthy",
|
|
61
|
+
checkStatuses: [check("c1", "healthy"), check("c2", "healthy")],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
it("is FALSE for a pure timestamp/runs refresh (same vector)", () => {
|
|
65
|
+
// Volatile fields (evaluatedAt / lastRunAt / runsConsidered) are not part of
|
|
66
|
+
// the fingerprint, so an object carrying different ones but the same vector
|
|
67
|
+
// is not a change. Represented here by an identical vector.
|
|
68
|
+
const next: StatusFingerprintInput = {
|
|
69
|
+
status: "healthy",
|
|
70
|
+
checkStatuses: [check("c1", "healthy"), check("c2", "healthy")],
|
|
71
|
+
};
|
|
72
|
+
expect(statusVectorChanged(base, next)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is TRUE for a per-check flip even when the rollup enum is unchanged", () => {
|
|
76
|
+
// Rollup stays "healthy" here (contrived), but c2 flipped — the entity view
|
|
77
|
+
// {status, healthyChecks, totalChecks} could miss this, the fingerprint does not.
|
|
78
|
+
const next: StatusFingerprintInput = {
|
|
79
|
+
status: "healthy",
|
|
80
|
+
checkStatuses: [check("c1", "healthy"), check("c2", "degraded")],
|
|
81
|
+
};
|
|
82
|
+
expect(statusVectorChanged(base, next)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("is TRUE when the check SET changes", () => {
|
|
86
|
+
const next: StatusFingerprintInput = {
|
|
87
|
+
status: "healthy",
|
|
88
|
+
checkStatuses: [check("c1", "healthy")],
|
|
89
|
+
};
|
|
90
|
+
expect(statusVectorChanged(base, next)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-check status fingerprint — the change-signal that gates system-health
|
|
3
|
+
* cache invalidation.
|
|
4
|
+
*
|
|
5
|
+
* The cached value is the full aggregated system-health response
|
|
6
|
+
* (`getSystemHealthStatus`), but most of its fields are VOLATILE: every run
|
|
7
|
+
* bumps `evaluatedAt`, a check's `lastRunAt`, and `runsConsidered` even when
|
|
8
|
+
* nothing an operator sees actually changed. Invalidating on those would defeat
|
|
9
|
+
* the cache (every tick evicts). The fingerprint is invariant to them: it
|
|
10
|
+
* captures ONLY the derived-status vector a reader gates on:
|
|
11
|
+
* - the system-wide rollup `status`, and
|
|
12
|
+
* - per check: `configurationId`, its derived `status`, and its slice
|
|
13
|
+
* composition (`sliceCount` / `failingSliceCount`).
|
|
14
|
+
*
|
|
15
|
+
* So `statusVectorChanged(prev, next)` is true exactly when a check flipped
|
|
16
|
+
* status, a slice began/stopped failing, or the check set changed — i.e. when a
|
|
17
|
+
* status a reader renders actually moved. A run that merely refreshes timestamps
|
|
18
|
+
* with the same vector is NOT a change, so the reconcile skips invalidation and
|
|
19
|
+
* the cached value (correct except for volatile fields no reader needs) survives
|
|
20
|
+
* to its TTL. This is the "broaden the change-signal to the per-check status
|
|
21
|
+
* vector" requirement: it catches a per-check flip that leaves the rollup enum
|
|
22
|
+
* unchanged (which the entity view's `{status, healthyChecks, totalChecks}`
|
|
23
|
+
* would miss), while ignoring pure timestamp churn.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Structural subset of `SystemHealthStatusResponse` the fingerprint reads. The
|
|
28
|
+
* service's response is assignable to this, so no import (and no cast) is needed
|
|
29
|
+
* — keeping this module a leaf the cache and executor can both depend on.
|
|
30
|
+
*/
|
|
31
|
+
export interface StatusFingerprintInput {
|
|
32
|
+
status: string;
|
|
33
|
+
checkStatuses: readonly {
|
|
34
|
+
configurationId: string;
|
|
35
|
+
status: string;
|
|
36
|
+
sliceCount: number;
|
|
37
|
+
failingSliceCount: number;
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A stable, order-independent fingerprint of a system-health response's derived
|
|
43
|
+
* status vector. Checks are sorted by `configurationId` so two responses with
|
|
44
|
+
* the same checks in a different order fingerprint identically.
|
|
45
|
+
*/
|
|
46
|
+
export function statusFingerprint(response: StatusFingerprintInput): string {
|
|
47
|
+
const perCheck = response.checkStatuses
|
|
48
|
+
.map(
|
|
49
|
+
(c) =>
|
|
50
|
+
`${c.configurationId}:${c.status}:${c.sliceCount}:${c.failingSliceCount}`,
|
|
51
|
+
)
|
|
52
|
+
.toSorted();
|
|
53
|
+
return `${response.status}|${perCheck.join(",")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether the derived status vector changed between two responses (ignoring
|
|
58
|
+
* volatile fields). This is the gate for cache invalidation + cross-pod
|
|
59
|
+
* broadcast: only a real vector change evicts the cache and wakes other pods.
|
|
60
|
+
*/
|
|
61
|
+
export function statusVectorChanged(
|
|
62
|
+
previous: StatusFingerprintInput,
|
|
63
|
+
next: StatusFingerprintInput,
|
|
64
|
+
): boolean {
|
|
65
|
+
return statusFingerprint(previous) !== statusFingerprint(next);
|
|
66
|
+
}
|
|
@@ -301,3 +301,87 @@ describe("health widgets — no environment filter (unchanged behavior)", () =>
|
|
|
301
301
|
expect(result.uptimePct).toBe(50);
|
|
302
302
|
});
|
|
303
303
|
});
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Send-time SCOPING regression: the subscriber fan-out only surfaces a HEALTH
|
|
307
|
+
* notification through a health widget, and only for the systems the widget
|
|
308
|
+
* CURRENTLY shows (its configured systems ∩ the page's published environments).
|
|
309
|
+
* Each health widget must therefore declare `subscriptionCategory: "health"` and
|
|
310
|
+
* a `resolveScopedSystems` that matches what `resolvePublic` renders.
|
|
311
|
+
*/
|
|
312
|
+
describe("health widgets — resolveScopedSystems (send-time scoping)", () => {
|
|
313
|
+
test("every health widget is tagged with the 'health' subscription category", () => {
|
|
314
|
+
const widgets = widgetsById();
|
|
315
|
+
for (const id of ["banner", "systemHealth", "groupStatus", "uptime"]) {
|
|
316
|
+
expect(widgets.get(id)!.subscriptionCategory).toBe("health");
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const scoped = ["prod"];
|
|
321
|
+
|
|
322
|
+
test("banner scopes its systems to the published environments", async () => {
|
|
323
|
+
const widget = widgetsById().get("banner")!;
|
|
324
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds: scoped });
|
|
325
|
+
const set = await widget.resolveScopedSystems!({
|
|
326
|
+
config: { systemIds: ["prod-sys", "stage-sys", "both-sys"] },
|
|
327
|
+
ctx,
|
|
328
|
+
});
|
|
329
|
+
expect([...set].toSorted()).toEqual(["both-sys", "prod-sys"]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("systemHealth scopes to env and applies per-row label overrides in the detailed list", async () => {
|
|
333
|
+
const widget = widgetsById().get("systemHealth")!;
|
|
334
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds: scoped });
|
|
335
|
+
const config = {
|
|
336
|
+
items: [
|
|
337
|
+
{ systemId: "prod-sys", label: "PROD!" },
|
|
338
|
+
{ systemId: "stage-sys" },
|
|
339
|
+
{ systemId: "both-sys" },
|
|
340
|
+
],
|
|
341
|
+
};
|
|
342
|
+
const set = await widget.resolveScopedSystems!({ config, ctx });
|
|
343
|
+
expect([...set].toSorted()).toEqual(["both-sys", "prod-sys"]);
|
|
344
|
+
const detailed = await widget.resolveScopedSystemsDetailed!({ config, ctx });
|
|
345
|
+
expect(detailed).toEqual([
|
|
346
|
+
{ id: "prod-sys", name: "PROD!" }, // label override wins over catalog name
|
|
347
|
+
{ id: "both-sys", name: "Both System" },
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("groupStatus scopes its expanded members to the published environments", async () => {
|
|
352
|
+
const widget = widgetsById().get("groupStatus")!;
|
|
353
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds: scoped });
|
|
354
|
+
const set = await widget.resolveScopedSystems!({
|
|
355
|
+
config: { groupId: "g1" },
|
|
356
|
+
ctx,
|
|
357
|
+
});
|
|
358
|
+
expect([...set]).toEqual(["prod-sys"]); // g1 = [prod-sys, stage-sys]; prod only
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("uptime scopes to its single system, emptying when it is out of scope", async () => {
|
|
362
|
+
const widget = widgetsById().get("uptime")!;
|
|
363
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds: scoped });
|
|
364
|
+
expect([
|
|
365
|
+
...(await widget.resolveScopedSystems!({
|
|
366
|
+
config: { systemId: "prod-sys", days: 30 },
|
|
367
|
+
ctx,
|
|
368
|
+
})),
|
|
369
|
+
]).toEqual(["prod-sys"]);
|
|
370
|
+
expect([
|
|
371
|
+
...(await widget.resolveScopedSystems!({
|
|
372
|
+
config: { systemId: "stage-sys", days: 30 },
|
|
373
|
+
ctx,
|
|
374
|
+
})),
|
|
375
|
+
]).toEqual([]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("no environment filter surfaces every configured system", async () => {
|
|
379
|
+
const widget = widgetsById().get("banner")!;
|
|
380
|
+
const ctx = makeCtx({ data: DATA }); // all environments
|
|
381
|
+
const set = await widget.resolveScopedSystems!({
|
|
382
|
+
config: { systemIds: ["prod-sys", "stage-sys", "both-sys"] },
|
|
383
|
+
ctx,
|
|
384
|
+
});
|
|
385
|
+
expect([...set].toSorted()).toEqual(["both-sys", "prod-sys", "stage-sys"]);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -68,6 +68,48 @@ async function scopeToEnv(
|
|
|
68
68
|
return ids.filter((id) => visible.has(id));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/*
|
|
72
|
+
* The CURRENT set of catalog system ids each health widget surfaces, resolved
|
|
73
|
+
* from the SAME config the DTO resolve reads and intersected with the page's
|
|
74
|
+
* published-environment scope. Shared by `resolvePublic` (what the widget shows)
|
|
75
|
+
* and `resolveScopedSystems` / `resolveScopedSystemsDetailed` (what the
|
|
76
|
+
* subscriber fan-out may email about), so the shown set and the emailed-about set
|
|
77
|
+
* can never drift.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
async function bannerScopedIds(
|
|
81
|
+
config: unknown,
|
|
82
|
+
ctx: WidgetResolveContext,
|
|
83
|
+
): Promise<string[]> {
|
|
84
|
+
return scopeToEnv(ctx, BannerConfigSchema.parse(config).systemIds);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function systemHealthScopedItems(
|
|
88
|
+
config: unknown,
|
|
89
|
+
ctx: WidgetResolveContext,
|
|
90
|
+
) {
|
|
91
|
+
const c = SystemHealthConfigSchema.parse(config);
|
|
92
|
+
const visible = await envVisibleSystems(ctx);
|
|
93
|
+
return visible ? c.items.filter((i) => visible.has(i.systemId)) : c.items;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function groupStatusScopedIds(
|
|
97
|
+
config: unknown,
|
|
98
|
+
ctx: WidgetResolveContext,
|
|
99
|
+
): Promise<string[]> {
|
|
100
|
+
const c = GroupStatusConfigSchema.parse(config);
|
|
101
|
+
const groups = await allGroups(ctx);
|
|
102
|
+
const group = groups.find((g) => g.id === c.groupId);
|
|
103
|
+
return scopeToEnv(ctx, group?.systemIds ?? []);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function uptimeScopedIds(
|
|
107
|
+
config: unknown,
|
|
108
|
+
ctx: WidgetResolveContext,
|
|
109
|
+
): Promise<string[]> {
|
|
110
|
+
return scopeToEnv(ctx, [UptimeConfigSchema.parse(config).systemId]);
|
|
111
|
+
}
|
|
112
|
+
|
|
71
113
|
function uptimeToStatus(pct: number): PublicStatus {
|
|
72
114
|
if (pct >= 99.5) return "operational";
|
|
73
115
|
if (pct >= 95) return "degraded";
|
|
@@ -228,10 +270,18 @@ const banner: WidgetTypeDefinition = {
|
|
|
228
270
|
assertBindingsReadable: assertSystems((c) => ({
|
|
229
271
|
systemIds: BannerConfigSchema.parse(c).systemIds,
|
|
230
272
|
})),
|
|
273
|
+
subscriptionCategory: "health",
|
|
274
|
+
resolveScopedSystems: async ({ config, ctx }) =>
|
|
275
|
+
new Set(await bannerScopedIds(config, ctx)),
|
|
276
|
+
async resolveScopedSystemsDetailed({ config, ctx }) {
|
|
277
|
+
const ids = await bannerScopedIds(config, ctx);
|
|
278
|
+
const names = await labelsFor(ctx, ids);
|
|
279
|
+
return ids.map((id) => ({ id, name: names.get(id) ?? id }));
|
|
280
|
+
},
|
|
231
281
|
async resolvePublic({ config, ctx }) {
|
|
232
282
|
const c = BannerConfigSchema.parse(config);
|
|
233
283
|
// Omit systems outside the page's published environments before rolling up.
|
|
234
|
-
const ids = await
|
|
284
|
+
const ids = await bannerScopedIds(config, ctx);
|
|
235
285
|
const health = await healthPublicStatuses(ctx, ids);
|
|
236
286
|
const maint = await inMaintenance(ctx, ids);
|
|
237
287
|
const status = overallBannerStatus(
|
|
@@ -260,13 +310,26 @@ const systemHealth: WidgetTypeDefinition = {
|
|
|
260
310
|
assertBindingsReadable: assertSystems((c) => ({
|
|
261
311
|
systemIds: SystemHealthConfigSchema.parse(c).items.map((i) => i.systemId),
|
|
262
312
|
})),
|
|
313
|
+
subscriptionCategory: "health",
|
|
314
|
+
async resolveScopedSystems({ config, ctx }) {
|
|
315
|
+
const items = await systemHealthScopedItems(config, ctx);
|
|
316
|
+
return new Set(items.map((i) => i.systemId));
|
|
317
|
+
},
|
|
318
|
+
async resolveScopedSystemsDetailed({ config, ctx }) {
|
|
319
|
+
const items = await systemHealthScopedItems(config, ctx);
|
|
320
|
+
const names = await labelsFor(
|
|
321
|
+
ctx,
|
|
322
|
+
items.map((i) => i.systemId),
|
|
323
|
+
);
|
|
324
|
+
return items.map((i) => ({
|
|
325
|
+
id: i.systemId,
|
|
326
|
+
name: i.label ?? names.get(i.systemId) ?? i.systemId,
|
|
327
|
+
}));
|
|
328
|
+
},
|
|
263
329
|
async resolvePublic({ config, ctx }) {
|
|
264
330
|
const c = SystemHealthConfigSchema.parse(config);
|
|
265
331
|
// Drop rows for systems outside the page's published environments.
|
|
266
|
-
const
|
|
267
|
-
const items = visible
|
|
268
|
-
? c.items.filter((i) => visible.has(i.systemId))
|
|
269
|
-
: c.items;
|
|
332
|
+
const items = await systemHealthScopedItems(config, ctx);
|
|
270
333
|
const ids = items.map((i) => i.systemId);
|
|
271
334
|
const health = await healthPublicStatuses(ctx, ids);
|
|
272
335
|
const maint = await inMaintenance(ctx, ids);
|
|
@@ -330,12 +393,20 @@ const groupStatus: WidgetTypeDefinition = {
|
|
|
330
393
|
assertBindingsReadable: assertSystems((c) => ({
|
|
331
394
|
groupIds: [GroupStatusConfigSchema.parse(c).groupId],
|
|
332
395
|
})),
|
|
396
|
+
subscriptionCategory: "health",
|
|
397
|
+
resolveScopedSystems: async ({ config, ctx }) =>
|
|
398
|
+
new Set(await groupStatusScopedIds(config, ctx)),
|
|
399
|
+
async resolveScopedSystemsDetailed({ config, ctx }) {
|
|
400
|
+
const ids = await groupStatusScopedIds(config, ctx);
|
|
401
|
+
const names = await labelsFor(ctx, ids);
|
|
402
|
+
return ids.map((id) => ({ id, name: names.get(id) ?? id }));
|
|
403
|
+
},
|
|
333
404
|
async resolvePublic({ config, ctx }) {
|
|
334
405
|
const c = GroupStatusConfigSchema.parse(config);
|
|
335
406
|
const groups = await allGroups(ctx);
|
|
336
407
|
const group = groups.find((g) => g.id === c.groupId);
|
|
337
408
|
// Omit group members outside the page's published environments.
|
|
338
|
-
const ids = await
|
|
409
|
+
const ids = await groupStatusScopedIds(config, ctx);
|
|
339
410
|
const health = await healthPublicStatuses(ctx, ids);
|
|
340
411
|
const maint = await inMaintenance(ctx, ids);
|
|
341
412
|
const names = await labelsFor(ctx, ids);
|
|
@@ -366,6 +437,16 @@ const uptime: WidgetTypeDefinition = {
|
|
|
366
437
|
assertBindingsReadable: assertSystems((c) => ({
|
|
367
438
|
systemIds: [UptimeConfigSchema.parse(c).systemId],
|
|
368
439
|
})),
|
|
440
|
+
subscriptionCategory: "health",
|
|
441
|
+
resolveScopedSystems: async ({ config, ctx }) =>
|
|
442
|
+
new Set(await uptimeScopedIds(config, ctx)),
|
|
443
|
+
async resolveScopedSystemsDetailed({ config, ctx }) {
|
|
444
|
+
const c = UptimeConfigSchema.parse(config);
|
|
445
|
+
const ids = await uptimeScopedIds(config, ctx);
|
|
446
|
+
if (ids.length === 0) return [];
|
|
447
|
+
const names = await labelsFor(ctx, ids);
|
|
448
|
+
return ids.map((id) => ({ id, name: c.label ?? names.get(id) ?? id }));
|
|
449
|
+
},
|
|
369
450
|
async resolvePublic({ config, ctx }) {
|
|
370
451
|
const c = UptimeConfigSchema.parse(config);
|
|
371
452
|
const names = await labelsFor(ctx, [c.systemId]);
|