@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
|
@@ -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
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
mapHealthStatus,
|
|
4
4
|
rollupStatus,
|
|
5
5
|
overallBannerStatus,
|
|
6
|
+
rollupSelectedEnvironments,
|
|
6
7
|
statusBannerTitle,
|
|
7
8
|
} from "./rollup";
|
|
8
9
|
|
|
@@ -45,6 +46,45 @@ describe("overallBannerStatus", () => {
|
|
|
45
46
|
});
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
describe("rollupSelectedEnvironments", () => {
|
|
50
|
+
const environments = {
|
|
51
|
+
prod: { status: "healthy" },
|
|
52
|
+
staging: { status: "unhealthy" },
|
|
53
|
+
dev: { status: "degraded" },
|
|
54
|
+
};
|
|
55
|
+
test("considers only the selected environments (worst-wins)", () => {
|
|
56
|
+
expect(
|
|
57
|
+
rollupSelectedEnvironments({
|
|
58
|
+
environments,
|
|
59
|
+
selectedEnvironmentIds: ["prod"],
|
|
60
|
+
}),
|
|
61
|
+
).toBe("operational");
|
|
62
|
+
expect(
|
|
63
|
+
rollupSelectedEnvironments({
|
|
64
|
+
environments,
|
|
65
|
+
selectedEnvironmentIds: ["prod", "staging"],
|
|
66
|
+
}),
|
|
67
|
+
).toBe("major_outage");
|
|
68
|
+
expect(
|
|
69
|
+
rollupSelectedEnvironments({
|
|
70
|
+
environments,
|
|
71
|
+
selectedEnvironmentIds: ["prod", "dev"],
|
|
72
|
+
}),
|
|
73
|
+
).toBe("degraded");
|
|
74
|
+
});
|
|
75
|
+
test("no slice in any selected env -> unknown", () => {
|
|
76
|
+
expect(
|
|
77
|
+
rollupSelectedEnvironments({
|
|
78
|
+
environments,
|
|
79
|
+
selectedEnvironmentIds: ["nonexistent"],
|
|
80
|
+
}),
|
|
81
|
+
).toBe("unknown");
|
|
82
|
+
expect(
|
|
83
|
+
rollupSelectedEnvironments({ environments, selectedEnvironmentIds: [] }),
|
|
84
|
+
).toBe("unknown");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
48
88
|
describe("statusBannerTitle", () => {
|
|
49
89
|
test("renders a human title", () => {
|
|
50
90
|
expect(statusBannerTitle("operational")).toBe("All systems operational");
|
|
@@ -58,6 +58,33 @@ export function overallBannerStatus(statuses: PublicStatus[]): PublicStatus {
|
|
|
58
58
|
return rollupStatus(known);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Roll up a system's PER-ENVIRONMENT health-check statuses into a single public
|
|
63
|
+
* status for a status page scoped to specific environments. ONLY the slices for
|
|
64
|
+
* the selected environments are considered (worst-status-wins via the public
|
|
65
|
+
* precedence); a system with no slice in any selected environment resolves to
|
|
66
|
+
* `unknown`. Used by the health widgets when a page publishes an explicit
|
|
67
|
+
* environment set, so a system in both prod and staging shows only its
|
|
68
|
+
* selected-environment health rather than the cross-environment rollup.
|
|
69
|
+
*
|
|
70
|
+
* This considers CHECK statuses only. Incident-forced overrides are whole-system
|
|
71
|
+
* (not environment-scoped), so the caller folds the override IN via worst-wins on
|
|
72
|
+
* top of this result (see `healthPublicStatuses`) - keeping this helper a pure
|
|
73
|
+
* per-environment checks rollup.
|
|
74
|
+
*/
|
|
75
|
+
export function rollupSelectedEnvironments(args: {
|
|
76
|
+
environments: Record<string, { status: string }>;
|
|
77
|
+
selectedEnvironmentIds: string[];
|
|
78
|
+
}): PublicStatus {
|
|
79
|
+
const { environments, selectedEnvironmentIds } = args;
|
|
80
|
+
const statuses: PublicStatus[] = [];
|
|
81
|
+
for (const envId of selectedEnvironmentIds) {
|
|
82
|
+
const slice = environments[envId];
|
|
83
|
+
if (slice) statuses.push(mapHealthStatus(slice.status));
|
|
84
|
+
}
|
|
85
|
+
return rollupStatus(statuses);
|
|
86
|
+
}
|
|
87
|
+
|
|
61
88
|
export function statusBannerTitle(status: PublicStatus): string {
|
|
62
89
|
switch (status) {
|
|
63
90
|
case "operational": {
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import type {
|
|
4
|
+
WidgetResolveContext,
|
|
5
|
+
WidgetTypeDefinition,
|
|
6
|
+
StatusWidgetTypeExtensionPoint,
|
|
7
|
+
} from "@checkstack/status-page-backend";
|
|
8
|
+
import { registerHealthcheckStatusWidgets } from "./widgets";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Widget-level regression coverage for status-page environment filtering (the
|
|
12
|
+
* gap that let the standalone uptime widget slip through). Exercises the four
|
|
13
|
+
* health widgets directly against a mocked trusted rpcClient, asserting that a
|
|
14
|
+
* published-environment set:
|
|
15
|
+
* - omits systems outside the selected environments from banner / systemHealth
|
|
16
|
+
* / groupStatus AND blanks the single-system uptime widget, and
|
|
17
|
+
* - folds the WHOLE-SYSTEM incident override into the env-scoped health rollup
|
|
18
|
+
* (a prod system whose prod checks are green but which is under an active
|
|
19
|
+
* incident-forced outage still reads as an outage on a prod-scoped page).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Per-system per-environment CHECKS status, as getBulkSystemHealthMatrix returns. */
|
|
23
|
+
type MatrixEnvStatus = "healthy" | "degraded" | "unhealthy";
|
|
24
|
+
|
|
25
|
+
interface MockData {
|
|
26
|
+
/** environmentId -> member system ids (catalog env->systems mapping). */
|
|
27
|
+
envSystems: Record<string, string[]>;
|
|
28
|
+
/** systemId -> display name. */
|
|
29
|
+
systemNames: Record<string, string>;
|
|
30
|
+
/** systemId -> per-environment CHECKS status. */
|
|
31
|
+
matrix: Record<string, Record<string, MatrixEnvStatus>>;
|
|
32
|
+
/** systemId -> whole-system incident override status (folded, worst-wins). */
|
|
33
|
+
overrides?: Record<string, MatrixEnvStatus>;
|
|
34
|
+
/** systemId -> total uptime percent (for the uptime widget). */
|
|
35
|
+
uptimePct?: Record<string, number>;
|
|
36
|
+
/** catalog groupId -> member system ids. */
|
|
37
|
+
groups?: Record<string, string[]>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function widgetsById(): Map<string, WidgetTypeDefinition> {
|
|
41
|
+
const map = new Map<string, WidgetTypeDefinition>();
|
|
42
|
+
const ext: StatusWidgetTypeExtensionPoint = {
|
|
43
|
+
registerWidgetType: (def) => map.set(def.id, def),
|
|
44
|
+
};
|
|
45
|
+
registerHealthcheckStatusWidgets(ext);
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeCtx(args: {
|
|
50
|
+
data: MockData;
|
|
51
|
+
publishedEnvironmentIds?: string[];
|
|
52
|
+
}): WidgetResolveContext {
|
|
53
|
+
const { data, publishedEnvironmentIds } = args;
|
|
54
|
+
const memo = new Map<string, Promise<unknown>>();
|
|
55
|
+
const api = {
|
|
56
|
+
// catalog
|
|
57
|
+
resolveEnvironments: async ({
|
|
58
|
+
environmentIds,
|
|
59
|
+
}: {
|
|
60
|
+
environmentIds: string[];
|
|
61
|
+
}) =>
|
|
62
|
+
environmentIds.map((id) => ({
|
|
63
|
+
id,
|
|
64
|
+
name: id,
|
|
65
|
+
description: null,
|
|
66
|
+
systemIds: data.envSystems[id] ?? [],
|
|
67
|
+
metadata: null,
|
|
68
|
+
createdAt: new Date(),
|
|
69
|
+
updatedAt: new Date(),
|
|
70
|
+
})),
|
|
71
|
+
getSystems: async () => ({
|
|
72
|
+
systems: Object.entries(data.systemNames).map(([id, name]) => ({
|
|
73
|
+
id,
|
|
74
|
+
name,
|
|
75
|
+
})),
|
|
76
|
+
}),
|
|
77
|
+
getGroups: async () =>
|
|
78
|
+
Object.entries(data.groups ?? {}).map(([id, systemIds]) => ({
|
|
79
|
+
id,
|
|
80
|
+
name: id,
|
|
81
|
+
systemIds,
|
|
82
|
+
})),
|
|
83
|
+
// maintenance
|
|
84
|
+
getBulkMaintenancesForSystems: async () => ({ maintenances: {} }),
|
|
85
|
+
// healthcheck
|
|
86
|
+
getBulkSystemHealthMatrix: async ({ systemIds }: { systemIds: string[] }) => {
|
|
87
|
+
const statuses: Record<
|
|
88
|
+
string,
|
|
89
|
+
{
|
|
90
|
+
status: MatrixEnvStatus;
|
|
91
|
+
checkStatuses: never[];
|
|
92
|
+
environments: Record<
|
|
93
|
+
string,
|
|
94
|
+
{ status: MatrixEnvStatus; checkStatuses: never[] }
|
|
95
|
+
>;
|
|
96
|
+
}
|
|
97
|
+
> = {};
|
|
98
|
+
for (const id of systemIds) {
|
|
99
|
+
const envs = data.matrix[id];
|
|
100
|
+
if (!envs) continue;
|
|
101
|
+
statuses[id] = {
|
|
102
|
+
status: "healthy",
|
|
103
|
+
checkStatuses: [],
|
|
104
|
+
environments: Object.fromEntries(
|
|
105
|
+
Object.entries(envs).map(([env, status]) => [
|
|
106
|
+
env,
|
|
107
|
+
{ status, checkStatuses: [] },
|
|
108
|
+
]),
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { statuses };
|
|
113
|
+
},
|
|
114
|
+
getBulkSystemHealthStatus: async ({ systemIds }: { systemIds: string[] }) => {
|
|
115
|
+
const statuses: Record<
|
|
116
|
+
string,
|
|
117
|
+
{
|
|
118
|
+
status: MatrixEnvStatus;
|
|
119
|
+
evaluatedAt: Date;
|
|
120
|
+
checkStatuses: never[];
|
|
121
|
+
override?: { status: MatrixEnvStatus; source: string; reason: string };
|
|
122
|
+
}
|
|
123
|
+
> = {};
|
|
124
|
+
for (const id of systemIds) {
|
|
125
|
+
const override = data.overrides?.[id];
|
|
126
|
+
statuses[id] = {
|
|
127
|
+
status: "healthy",
|
|
128
|
+
evaluatedAt: new Date(),
|
|
129
|
+
checkStatuses: [],
|
|
130
|
+
...(override
|
|
131
|
+
? { override: { status: override, source: "incident", reason: "x" } }
|
|
132
|
+
: {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { statuses };
|
|
136
|
+
},
|
|
137
|
+
getRunStats: async ({ systemId }: { systemId: string }) => {
|
|
138
|
+
const pct = data.uptimePct?.[systemId] ?? 100;
|
|
139
|
+
return {
|
|
140
|
+
window: { start: "2026-07-01T00:00:00Z", end: "2026-07-02T00:00:00Z" },
|
|
141
|
+
bucketIntervalSeconds: 86_400,
|
|
142
|
+
total: {
|
|
143
|
+
runCount: 10,
|
|
144
|
+
healthy: 10,
|
|
145
|
+
degraded: 0,
|
|
146
|
+
unhealthy: 0,
|
|
147
|
+
uptimePct: pct,
|
|
148
|
+
},
|
|
149
|
+
buckets: [
|
|
150
|
+
{
|
|
151
|
+
start: "2026-07-01T00:00:00Z",
|
|
152
|
+
end: "2026-07-02T00:00:00Z",
|
|
153
|
+
runCount: 10,
|
|
154
|
+
healthy: 10,
|
|
155
|
+
degraded: 0,
|
|
156
|
+
unhealthy: 0,
|
|
157
|
+
uptimePct: pct,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
rpcClient: { forPlugin: () => api } as unknown as RpcClient,
|
|
165
|
+
cache: <T,>(key: string, loader: () => Promise<T>): Promise<T> => {
|
|
166
|
+
const existing = memo.get(key);
|
|
167
|
+
if (existing) return existing as Promise<T>;
|
|
168
|
+
const created = loader();
|
|
169
|
+
memo.set(key, created);
|
|
170
|
+
return created;
|
|
171
|
+
},
|
|
172
|
+
publishedEnvironmentIds,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// prod-sys: prod only (checks healthy, but under an active incident override).
|
|
177
|
+
// stage-sys: staging only (checks unhealthy).
|
|
178
|
+
// both-sys: prod + staging (prod healthy, staging unhealthy).
|
|
179
|
+
const DATA: MockData = {
|
|
180
|
+
envSystems: {
|
|
181
|
+
prod: ["prod-sys", "both-sys"],
|
|
182
|
+
stage: ["stage-sys", "both-sys"],
|
|
183
|
+
},
|
|
184
|
+
systemNames: {
|
|
185
|
+
"prod-sys": "Prod System",
|
|
186
|
+
"stage-sys": "Stage System",
|
|
187
|
+
"both-sys": "Both System",
|
|
188
|
+
},
|
|
189
|
+
matrix: {
|
|
190
|
+
"prod-sys": { prod: "healthy" },
|
|
191
|
+
"stage-sys": { stage: "unhealthy" },
|
|
192
|
+
"both-sys": { prod: "healthy", stage: "unhealthy" },
|
|
193
|
+
},
|
|
194
|
+
overrides: { "prod-sys": "unhealthy" }, // whole-system incident-forced outage
|
|
195
|
+
uptimePct: { "prod-sys": 99, "stage-sys": 50, "both-sys": 88 },
|
|
196
|
+
groups: { g1: ["prod-sys", "stage-sys"] },
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
describe("health widgets — environment filtering (E3 regression)", () => {
|
|
200
|
+
const publishedEnvironmentIds = ["prod"];
|
|
201
|
+
|
|
202
|
+
test("systemHealth omits staging-only systems and folds the whole-system override", async () => {
|
|
203
|
+
const widget = widgetsById().get("systemHealth")!;
|
|
204
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
|
|
205
|
+
const result = (await widget.resolvePublic({
|
|
206
|
+
config: {
|
|
207
|
+
items: [
|
|
208
|
+
{ systemId: "prod-sys" },
|
|
209
|
+
{ systemId: "stage-sys" },
|
|
210
|
+
{ systemId: "both-sys" },
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
ctx,
|
|
214
|
+
})) as { systems: Array<{ label: string; status: string }> };
|
|
215
|
+
// stage-sys is dropped; prod-sys shows the incident override (major_outage)
|
|
216
|
+
// even though its prod checks are healthy; both-sys shows its prod checks.
|
|
217
|
+
expect(result.systems).toEqual([
|
|
218
|
+
{ label: "Prod System", status: "major_outage" },
|
|
219
|
+
{ label: "Both System", status: "operational" },
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("banner rolls up only env-visible systems, override included", async () => {
|
|
224
|
+
const widget = widgetsById().get("banner")!;
|
|
225
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
|
|
226
|
+
const result = (await widget.resolvePublic({
|
|
227
|
+
config: { systemIds: ["prod-sys", "stage-sys", "both-sys"] },
|
|
228
|
+
ctx,
|
|
229
|
+
})) as { status: string };
|
|
230
|
+
// Visible = {prod-sys (major via override), both-sys (operational)} ->
|
|
231
|
+
// some-but-not-all down = partial_outage. stage-sys never counted.
|
|
232
|
+
expect(result.status).toBe("partial_outage");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("groupStatus drops group members outside the published environments", async () => {
|
|
236
|
+
const widget = widgetsById().get("groupStatus")!;
|
|
237
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
|
|
238
|
+
const result = (await widget.resolvePublic({
|
|
239
|
+
config: { groupId: "g1" },
|
|
240
|
+
ctx,
|
|
241
|
+
})) as { systems: Array<{ label: string; status: string }>; status: string };
|
|
242
|
+
// g1 = [prod-sys, stage-sys]; only prod-sys is in prod. Its override lifts
|
|
243
|
+
// it to major_outage; stage-sys is omitted entirely.
|
|
244
|
+
expect(result.systems).toEqual([
|
|
245
|
+
{ label: "Prod System", status: "major_outage" },
|
|
246
|
+
]);
|
|
247
|
+
expect(result.status).toBe("major_outage");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("uptime widget is blanked for a system outside the published environments", async () => {
|
|
251
|
+
const widget = widgetsById().get("uptime")!;
|
|
252
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
|
|
253
|
+
const result = (await widget.resolvePublic({
|
|
254
|
+
config: { systemId: "stage-sys", days: 30 },
|
|
255
|
+
ctx,
|
|
256
|
+
})) as { label: string; uptimePct: number; bars: unknown[] };
|
|
257
|
+
// Out of scope -> blank empty-state DTO (no bars, no misleading percent).
|
|
258
|
+
expect(result.bars).toEqual([]);
|
|
259
|
+
expect(result.uptimePct).toBe(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("uptime widget renders normally for an in-scope system", async () => {
|
|
263
|
+
const widget = widgetsById().get("uptime")!;
|
|
264
|
+
const ctx = makeCtx({ data: DATA, publishedEnvironmentIds });
|
|
265
|
+
const result = (await widget.resolvePublic({
|
|
266
|
+
config: { systemId: "prod-sys", days: 30 },
|
|
267
|
+
ctx,
|
|
268
|
+
})) as { label: string; uptimePct: number; bars: unknown[] };
|
|
269
|
+
expect(result.label).toBe("Prod System");
|
|
270
|
+
expect(result.bars.length).toBe(1);
|
|
271
|
+
expect(result.uptimePct).toBe(99);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("health widgets — no environment filter (unchanged behavior)", () => {
|
|
276
|
+
test("systemHealth keeps every system and uses the folded cross-env status", async () => {
|
|
277
|
+
const widget = widgetsById().get("systemHealth")!;
|
|
278
|
+
const ctx = makeCtx({ data: DATA }); // no publishedEnvironmentIds
|
|
279
|
+
const result = (await widget.resolvePublic({
|
|
280
|
+
config: {
|
|
281
|
+
items: [{ systemId: "prod-sys" }, { systemId: "stage-sys" }],
|
|
282
|
+
},
|
|
283
|
+
ctx,
|
|
284
|
+
})) as { systems: Array<{ label: string; status: string }> };
|
|
285
|
+
// All-env path reads getBulkSystemHealthStatus.status (healthy in the mock)
|
|
286
|
+
// for both systems; no system is dropped.
|
|
287
|
+
expect(result.systems).toEqual([
|
|
288
|
+
{ label: "Prod System", status: "operational" },
|
|
289
|
+
{ label: "Stage System", status: "operational" },
|
|
290
|
+
]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("uptime widget is never blanked when no environment filter is set", async () => {
|
|
294
|
+
const widget = widgetsById().get("uptime")!;
|
|
295
|
+
const ctx = makeCtx({ data: DATA });
|
|
296
|
+
const result = (await widget.resolvePublic({
|
|
297
|
+
config: { systemId: "stage-sys", days: 30 },
|
|
298
|
+
ctx,
|
|
299
|
+
})) as { bars: unknown[]; uptimePct: number };
|
|
300
|
+
expect(result.bars.length).toBe(1);
|
|
301
|
+
expect(result.uptimePct).toBe(50);
|
|
302
|
+
});
|
|
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
|
+
});
|