@checkstack/healthcheck-backend 1.19.0 → 1.20.1

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/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. The lookup index leads with
1170
- // (system_id, environment_id, …) so the env-scoped query is index-efficient.
1169
+ // filters to that environment. `health_check_runs_slice_recent_idx`
1170
+ // (system_id, configuration_id, environment_id, timestamp) serves the
1171
+ // env-scoped ORDER BY timestamp DESC LIMIT read as an index range scan.
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
- // Distinct systemIds that have at least one ENABLED check association.
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
- rows.map(async ({ systemId }) => {
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
- * Bulk per-(system, check, environment) health for the given systems.
1489
- *
1490
- * For each system returns the cross-environment rollup (status +
1491
- * checkStatuses, same as {@link getSystemHealthStatus}) PLUS a slice per
1492
- * environment the system has runs for. Consumers that scope by environment
1493
- * (the dependency map) must read the per-environment slice, because the
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 getBulkSystemHealthMatrix(systemIds: string[]): Promise<
1501
- Record<
1502
- string,
1503
- {
1504
- status: HealthCheckStatus;
1505
- checkStatuses: SystemHealthStatusResponse["checkStatuses"];
1506
- environments: Record<
1507
- string,
1508
- {
1509
- status: HealthCheckStatus;
1510
- checkStatuses: SystemHealthStatusResponse["checkStatuses"];
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). The
1652
- // (system_id, configuration_id, environment_id, timestamp) index makes
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 scopeToEnv(ctx, c.systemIds);
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 visible = await envVisibleSystems(ctx);
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 scopeToEnv(ctx, group?.systemIds ?? []);
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]);