@checkstack/healthcheck-backend 1.18.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +294 -0
- package/package.json +22 -20
- package/src/health-notification-content.test.ts +89 -0
- package/src/health-notification-content.ts +138 -0
- package/src/queue-executor.ts +31 -68
- package/src/router.ts +36 -0
- 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 +366 -185
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +303 -0
- package/src/status-page/widgets.ts +155 -39
|
@@ -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,303 @@
|
|
|
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
|
+
});
|
|
@@ -23,12 +23,51 @@ import {
|
|
|
23
23
|
mapHealthStatus,
|
|
24
24
|
rollupStatus,
|
|
25
25
|
overallBannerStatus,
|
|
26
|
+
rollupSelectedEnvironments,
|
|
26
27
|
statusBannerTitle,
|
|
27
28
|
} from "./rollup";
|
|
28
29
|
|
|
29
30
|
const SYSTEM_TYPE = "catalog.system";
|
|
30
31
|
const GROUP_TYPE = "catalog.group";
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The catalog system ids visible under the page's published-environment scope,
|
|
35
|
+
* or null when the page publishes all environments (no filter). Resolved once
|
|
36
|
+
* per page resolve (cache-keyed by the env set) from the catalog's own
|
|
37
|
+
* env->systems mapping, so a system in NONE of the selected environments is
|
|
38
|
+
* omitted from every health widget.
|
|
39
|
+
*/
|
|
40
|
+
async function envVisibleSystems(
|
|
41
|
+
ctx: WidgetResolveContext,
|
|
42
|
+
): Promise<Set<string> | null> {
|
|
43
|
+
const envIds = ctx.publishedEnvironmentIds;
|
|
44
|
+
if (!envIds || envIds.length === 0) return null;
|
|
45
|
+
const key = `catalog.systemsInEnv:${[...envIds].toSorted().join(",")}`;
|
|
46
|
+
const ids = await ctx.cache(key, async () => {
|
|
47
|
+
const envs = await ctx.rpcClient
|
|
48
|
+
.forPlugin(CatalogApi)
|
|
49
|
+
.resolveEnvironments({ environmentIds: envIds });
|
|
50
|
+
const set = new Set<string>();
|
|
51
|
+
for (const env of envs) for (const s of env.systemIds) set.add(s);
|
|
52
|
+
return [...set];
|
|
53
|
+
});
|
|
54
|
+
return new Set(ids);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Restrict a widget's configured system ids to those visible under the page's
|
|
59
|
+
* published-environment scope. Returns the ids unchanged when the page publishes
|
|
60
|
+
* all environments.
|
|
61
|
+
*/
|
|
62
|
+
async function scopeToEnv(
|
|
63
|
+
ctx: WidgetResolveContext,
|
|
64
|
+
ids: string[],
|
|
65
|
+
): Promise<string[]> {
|
|
66
|
+
const visible = await envVisibleSystems(ctx);
|
|
67
|
+
if (!visible) return ids;
|
|
68
|
+
return ids.filter((id) => visible.has(id));
|
|
69
|
+
}
|
|
70
|
+
|
|
32
71
|
function uptimeToStatus(pct: number): PublicStatus {
|
|
33
72
|
if (pct >= 99.5) return "operational";
|
|
34
73
|
if (pct >= 95) return "degraded";
|
|
@@ -83,25 +122,69 @@ async function inMaintenance(
|
|
|
83
122
|
return out;
|
|
84
123
|
}
|
|
85
124
|
|
|
86
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Per-system HEALTH-derived PUBLIC status (before the maintenance override).
|
|
127
|
+
*
|
|
128
|
+
* - Page publishes ALL environments: the cross-environment rollup via
|
|
129
|
+
* `getBulkSystemHealthStatus`, which folds active incident overrides into the
|
|
130
|
+
* status (so the public page shows the forced status).
|
|
131
|
+
* - Page publishes a SPECIFIC environment set: the per-environment matrix rolled
|
|
132
|
+
* up over ONLY the selected environments (`rollupSelectedEnvironments`), then
|
|
133
|
+
* the whole-system incident override folded IN via worst-wins. Incident
|
|
134
|
+
* overrides are whole-system (not env-scoped), so they must apply regardless of
|
|
135
|
+
* which environments the page publishes - otherwise a system whose checks are
|
|
136
|
+
* green in the selected env but which is under an active incident-forced outage
|
|
137
|
+
* would wrongly read healthy. The override status comes from the SAME source as
|
|
138
|
+
* the all-env path (`getBulkSystemHealthStatus`, which folds it and surfaces it
|
|
139
|
+
* on `override`), so both modes show the identical forced status.
|
|
140
|
+
*
|
|
141
|
+
* In BOTH modes only the derived status enum is read - never `override.reason`
|
|
142
|
+
* (the incident TITLE) or per-check detail - so no internal name reaches a
|
|
143
|
+
* public widget DTO.
|
|
144
|
+
*/
|
|
145
|
+
async function healthPublicStatuses(
|
|
87
146
|
ctx: WidgetResolveContext,
|
|
88
147
|
ids: string[],
|
|
89
|
-
): Promise<
|
|
90
|
-
|
|
148
|
+
): Promise<Map<string, PublicStatus>> {
|
|
149
|
+
const out = new Map<string, PublicStatus>();
|
|
150
|
+
if (ids.length === 0) return out;
|
|
151
|
+
const envIds = ctx.publishedEnvironmentIds;
|
|
152
|
+
if (envIds && envIds.length > 0) {
|
|
153
|
+
const client = ctx.rpcClient.forPlugin(HealthCheckApi);
|
|
154
|
+
// Matrix = per-environment CHECKS status; bulk status carries the
|
|
155
|
+
// whole-system incident override (on `override`). Fetch both, roll up only
|
|
156
|
+
// the selected envs' checks, then fold the override in (worst-wins).
|
|
157
|
+
const [matrixRes, bulkRes] = await Promise.all([
|
|
158
|
+
client.getBulkSystemHealthMatrix({ systemIds: ids }),
|
|
159
|
+
client.getBulkSystemHealthStatus({ systemIds: ids }),
|
|
160
|
+
]);
|
|
161
|
+
for (const id of ids) {
|
|
162
|
+
const matrix = matrixRes.statuses[id];
|
|
163
|
+
const envChecks: PublicStatus = matrix
|
|
164
|
+
? rollupSelectedEnvironments({
|
|
165
|
+
environments: matrix.environments,
|
|
166
|
+
selectedEnvironmentIds: envIds,
|
|
167
|
+
})
|
|
168
|
+
: "unknown";
|
|
169
|
+
const overrideStatus = bulkRes.statuses[id]?.override?.status;
|
|
170
|
+
// rollupStatus is worst-wins over the public vocabulary, so the override
|
|
171
|
+
// lifts the status exactly as `applySystemHealthOverrides` does upstream.
|
|
172
|
+
out.set(
|
|
173
|
+
id,
|
|
174
|
+
overrideStatus
|
|
175
|
+
? rollupStatus([envChecks, mapHealthStatus(overrideStatus)])
|
|
176
|
+
: envChecks,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
91
181
|
const { statuses } = await ctx.rpcClient
|
|
92
182
|
.forPlugin(HealthCheckApi)
|
|
93
183
|
.getBulkSystemHealthStatus({ systemIds: ids });
|
|
94
|
-
// Project to ONLY the derived status. `getBulkSystemHealthStatus` folds active
|
|
95
|
-
// incident overrides into `status` (so the public page shows the forced
|
|
96
|
-
// status), but the response also carries `override.reason` = the incident
|
|
97
|
-
// TITLE. Status pages are public and incidents may be hidden, so we drop
|
|
98
|
-
// everything but the status here - the incident name must never reach a public
|
|
99
|
-
// widget DTO.
|
|
100
|
-
const projected: Record<string, { status: string } | undefined> = {};
|
|
101
184
|
for (const [systemId, value] of Object.entries(statuses)) {
|
|
102
|
-
|
|
185
|
+
if (value) out.set(systemId, mapHealthStatus(value.status));
|
|
103
186
|
}
|
|
104
|
-
return
|
|
187
|
+
return out;
|
|
105
188
|
}
|
|
106
189
|
|
|
107
190
|
function publicStatus({
|
|
@@ -110,12 +193,11 @@ function publicStatus({
|
|
|
110
193
|
maint,
|
|
111
194
|
}: {
|
|
112
195
|
systemId: string;
|
|
113
|
-
health:
|
|
196
|
+
health: Map<string, PublicStatus>;
|
|
114
197
|
maint: Set<string>;
|
|
115
198
|
}): PublicStatus {
|
|
116
199
|
if (maint.has(systemId)) return "maintenance";
|
|
117
|
-
|
|
118
|
-
return internal ? mapHealthStatus(internal) : "unknown";
|
|
200
|
+
return health.get(systemId) ?? "unknown";
|
|
119
201
|
}
|
|
120
202
|
|
|
121
203
|
/** assertBindingsReadable for system-bound widgets. */
|
|
@@ -148,10 +230,12 @@ const banner: WidgetTypeDefinition = {
|
|
|
148
230
|
})),
|
|
149
231
|
async resolvePublic({ config, ctx }) {
|
|
150
232
|
const c = BannerConfigSchema.parse(config);
|
|
151
|
-
|
|
152
|
-
const
|
|
233
|
+
// Omit systems outside the page's published environments before rolling up.
|
|
234
|
+
const ids = await scopeToEnv(ctx, c.systemIds);
|
|
235
|
+
const health = await healthPublicStatuses(ctx, ids);
|
|
236
|
+
const maint = await inMaintenance(ctx, ids);
|
|
153
237
|
const status = overallBannerStatus(
|
|
154
|
-
|
|
238
|
+
ids.map((systemId) => publicStatus({ systemId, health, maint })),
|
|
155
239
|
);
|
|
156
240
|
return BannerDtoSchema.parse({
|
|
157
241
|
status,
|
|
@@ -178,12 +262,17 @@ const systemHealth: WidgetTypeDefinition = {
|
|
|
178
262
|
})),
|
|
179
263
|
async resolvePublic({ config, ctx }) {
|
|
180
264
|
const c = SystemHealthConfigSchema.parse(config);
|
|
181
|
-
|
|
182
|
-
const
|
|
265
|
+
// Drop rows for systems outside the page's published environments.
|
|
266
|
+
const visible = await envVisibleSystems(ctx);
|
|
267
|
+
const items = visible
|
|
268
|
+
? c.items.filter((i) => visible.has(i.systemId))
|
|
269
|
+
: c.items;
|
|
270
|
+
const ids = items.map((i) => i.systemId);
|
|
271
|
+
const health = await healthPublicStatuses(ctx, ids);
|
|
183
272
|
const maint = await inMaintenance(ctx, ids);
|
|
184
273
|
const names = await labelsFor(ctx, ids);
|
|
185
274
|
const uptime = c.showUptime ? await uptimeMap(ctx, ids) : undefined;
|
|
186
|
-
const systems =
|
|
275
|
+
const systems = items.map((item) => {
|
|
187
276
|
const pct = uptime?.get(item.systemId);
|
|
188
277
|
return {
|
|
189
278
|
label: item.label ?? names.get(item.systemId) ?? item.systemId,
|
|
@@ -199,22 +288,30 @@ async function uptimeMap(
|
|
|
199
288
|
ctx: WidgetResolveContext,
|
|
200
289
|
ids: string[],
|
|
201
290
|
): Promise<Map<string, number>> {
|
|
291
|
+
const out = new Map<string, number>();
|
|
292
|
+
if (ids.length === 0) return out;
|
|
202
293
|
const end = new Date();
|
|
203
294
|
const start = new Date(end.getTime() - 30 * 86_400_000);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
295
|
+
// ONE bulk call for every system's uptime, instead of an N+1 fan-out of
|
|
296
|
+
// per-system `getRunStats` (each holding a pooled connection). Systems with
|
|
297
|
+
// no runs are omitted from `stats`, so they never enter the map - preserving
|
|
298
|
+
// the previous "no runs => no misleading 0.00%" behavior exactly. When the
|
|
299
|
+
// page publishes a specific environment set, uptime counts only runs in those
|
|
300
|
+
// environments (env-less runs excluded).
|
|
301
|
+
const { stats } = await ctx.rpcClient
|
|
302
|
+
.forPlugin(HealthCheckApi)
|
|
303
|
+
.getBulkRunStats({
|
|
304
|
+
systemIds: ids,
|
|
305
|
+
startDate: start,
|
|
306
|
+
endDate: end,
|
|
307
|
+
...(ctx.publishedEnvironmentIds
|
|
308
|
+
? { environmentIds: ctx.publishedEnvironmentIds }
|
|
309
|
+
: {}),
|
|
310
|
+
maxBuckets: 1,
|
|
311
|
+
});
|
|
312
|
+
for (const systemId of ids) {
|
|
313
|
+
const s = stats[systemId];
|
|
314
|
+
if (s && s.total.runCount > 0) out.set(systemId, s.total.uptimePct);
|
|
218
315
|
}
|
|
219
316
|
return out;
|
|
220
317
|
}
|
|
@@ -237,8 +334,9 @@ const groupStatus: WidgetTypeDefinition = {
|
|
|
237
334
|
const c = GroupStatusConfigSchema.parse(config);
|
|
238
335
|
const groups = await allGroups(ctx);
|
|
239
336
|
const group = groups.find((g) => g.id === c.groupId);
|
|
240
|
-
|
|
241
|
-
const
|
|
337
|
+
// Omit group members outside the page's published environments.
|
|
338
|
+
const ids = await scopeToEnv(ctx, group?.systemIds ?? []);
|
|
339
|
+
const health = await healthPublicStatuses(ctx, ids);
|
|
242
340
|
const maint = await inMaintenance(ctx, ids);
|
|
243
341
|
const names = await labelsFor(ctx, ids);
|
|
244
342
|
const systems = ids.map((systemId) => ({
|
|
@@ -249,6 +347,7 @@ const groupStatus: WidgetTypeDefinition = {
|
|
|
249
347
|
label: c.label ?? group?.name ?? "Group",
|
|
250
348
|
status: rollupStatus(systems.map((s) => s.status)),
|
|
251
349
|
systems,
|
|
350
|
+
collapseWhenHealthy: c.collapseWhenHealthy,
|
|
252
351
|
});
|
|
253
352
|
},
|
|
254
353
|
};
|
|
@@ -269,17 +368,34 @@ const uptime: WidgetTypeDefinition = {
|
|
|
269
368
|
})),
|
|
270
369
|
async resolvePublic({ config, ctx }) {
|
|
271
370
|
const c = UptimeConfigSchema.parse(config);
|
|
371
|
+
const names = await labelsFor(ctx, [c.systemId]);
|
|
372
|
+
const label = c.label ?? names.get(c.systemId) ?? c.systemId;
|
|
373
|
+
// Omit this system's uptime when it is outside the page's published
|
|
374
|
+
// environments: emit the blank empty-state DTO (empty bars) the sibling
|
|
375
|
+
// health widgets use for out-of-scope systems, so the single-system uptime
|
|
376
|
+
// widget never shows misleading or stale-environment uptime. Gated on the
|
|
377
|
+
// CURRENT catalog env membership (`envVisibleSystems`), NOT on "getRunStats
|
|
378
|
+
// returned nothing" - so a system removed from the published env but still
|
|
379
|
+
// carrying old env-tagged runs is correctly blanked. No env filter (visible
|
|
380
|
+
// is null) leaves behavior unchanged.
|
|
381
|
+
const visible = await envVisibleSystems(ctx);
|
|
382
|
+
if (visible && !visible.has(c.systemId)) {
|
|
383
|
+
return UptimeDtoSchema.parse({ label, uptimePct: 0, bars: [] });
|
|
384
|
+
}
|
|
272
385
|
const end = new Date();
|
|
273
386
|
const start = new Date(end.getTime() - c.days * 86_400_000);
|
|
274
387
|
const stats = await ctx.rpcClient.forPlugin(HealthCheckApi).getRunStats({
|
|
275
388
|
systemId: c.systemId,
|
|
276
389
|
startDate: start,
|
|
277
390
|
endDate: end,
|
|
391
|
+
// Scope uptime to the page's published environments when set.
|
|
392
|
+
...(ctx.publishedEnvironmentIds
|
|
393
|
+
? { environmentIds: ctx.publishedEnvironmentIds }
|
|
394
|
+
: {}),
|
|
278
395
|
maxBuckets: c.days,
|
|
279
396
|
});
|
|
280
|
-
const names = await labelsFor(ctx, [c.systemId]);
|
|
281
397
|
return UptimeDtoSchema.parse({
|
|
282
|
-
label
|
|
398
|
+
label,
|
|
283
399
|
uptimePct: stats.total.uptimePct,
|
|
284
400
|
bars: stats.buckets.map((b) => ({
|
|
285
401
|
date: b.start,
|