@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.
@@ -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
- async function healthStatuses(
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<Record<string, { status: string } | undefined>> {
90
- if (ids.length === 0) return {};
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
- projected[systemId] = value ? { status: value.status } : undefined;
185
+ if (value) out.set(systemId, mapHealthStatus(value.status));
103
186
  }
104
- return projected;
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: Record<string, { status: string } | undefined>;
196
+ health: Map<string, PublicStatus>;
114
197
  maint: Set<string>;
115
198
  }): PublicStatus {
116
199
  if (maint.has(systemId)) return "maintenance";
117
- const internal = health[systemId]?.status;
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
- const health = await healthStatuses(ctx, c.systemIds);
152
- const maint = await inMaintenance(ctx, c.systemIds);
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
- c.systemIds.map((systemId) => publicStatus({ systemId, health, maint })),
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
- const ids = c.items.map((i) => i.systemId);
182
- const health = await healthStatuses(ctx, ids);
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 = c.items.map((item) => {
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
- const out = new Map<string, number>();
205
- const results = await Promise.allSettled(
206
- ids.map(async (systemId) => {
207
- const stats = await ctx.rpcClient
208
- .forPlugin(HealthCheckApi)
209
- .getRunStats({ systemId, startDate: start, endDate: end, maxBuckets: 1 });
210
- // No runs in the window => no uptime to report (don't surface a
211
- // misleading 0.00% for a system with no history).
212
- if (stats.total.runCount === 0) return null;
213
- return [systemId, stats.total.uptimePct] as const;
214
- }),
215
- );
216
- for (const r of results) {
217
- if (r.status === "fulfilled" && r.value) out.set(r.value[0], r.value[1]);
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
- const ids = group?.systemIds ?? [];
241
- const health = await healthStatuses(ctx, ids);
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: c.label ?? names.get(c.systemId) ?? c.systemId,
398
+ label,
283
399
  uptimePct: stats.total.uptimePct,
284
400
  bars: stats.buckets.map((b) => ({
285
401
  date: b.start,