@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
|
@@ -23,12 +23,93 @@ 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
|
+
|
|
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
|
+
|
|
32
113
|
function uptimeToStatus(pct: number): PublicStatus {
|
|
33
114
|
if (pct >= 99.5) return "operational";
|
|
34
115
|
if (pct >= 95) return "degraded";
|
|
@@ -83,25 +164,69 @@ async function inMaintenance(
|
|
|
83
164
|
return out;
|
|
84
165
|
}
|
|
85
166
|
|
|
86
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Per-system HEALTH-derived PUBLIC status (before the maintenance override).
|
|
169
|
+
*
|
|
170
|
+
* - Page publishes ALL environments: the cross-environment rollup via
|
|
171
|
+
* `getBulkSystemHealthStatus`, which folds active incident overrides into the
|
|
172
|
+
* status (so the public page shows the forced status).
|
|
173
|
+
* - Page publishes a SPECIFIC environment set: the per-environment matrix rolled
|
|
174
|
+
* up over ONLY the selected environments (`rollupSelectedEnvironments`), then
|
|
175
|
+
* the whole-system incident override folded IN via worst-wins. Incident
|
|
176
|
+
* overrides are whole-system (not env-scoped), so they must apply regardless of
|
|
177
|
+
* which environments the page publishes - otherwise a system whose checks are
|
|
178
|
+
* green in the selected env but which is under an active incident-forced outage
|
|
179
|
+
* would wrongly read healthy. The override status comes from the SAME source as
|
|
180
|
+
* the all-env path (`getBulkSystemHealthStatus`, which folds it and surfaces it
|
|
181
|
+
* on `override`), so both modes show the identical forced status.
|
|
182
|
+
*
|
|
183
|
+
* In BOTH modes only the derived status enum is read - never `override.reason`
|
|
184
|
+
* (the incident TITLE) or per-check detail - so no internal name reaches a
|
|
185
|
+
* public widget DTO.
|
|
186
|
+
*/
|
|
187
|
+
async function healthPublicStatuses(
|
|
87
188
|
ctx: WidgetResolveContext,
|
|
88
189
|
ids: string[],
|
|
89
|
-
): Promise<
|
|
90
|
-
|
|
190
|
+
): Promise<Map<string, PublicStatus>> {
|
|
191
|
+
const out = new Map<string, PublicStatus>();
|
|
192
|
+
if (ids.length === 0) return out;
|
|
193
|
+
const envIds = ctx.publishedEnvironmentIds;
|
|
194
|
+
if (envIds && envIds.length > 0) {
|
|
195
|
+
const client = ctx.rpcClient.forPlugin(HealthCheckApi);
|
|
196
|
+
// Matrix = per-environment CHECKS status; bulk status carries the
|
|
197
|
+
// whole-system incident override (on `override`). Fetch both, roll up only
|
|
198
|
+
// the selected envs' checks, then fold the override in (worst-wins).
|
|
199
|
+
const [matrixRes, bulkRes] = await Promise.all([
|
|
200
|
+
client.getBulkSystemHealthMatrix({ systemIds: ids }),
|
|
201
|
+
client.getBulkSystemHealthStatus({ systemIds: ids }),
|
|
202
|
+
]);
|
|
203
|
+
for (const id of ids) {
|
|
204
|
+
const matrix = matrixRes.statuses[id];
|
|
205
|
+
const envChecks: PublicStatus = matrix
|
|
206
|
+
? rollupSelectedEnvironments({
|
|
207
|
+
environments: matrix.environments,
|
|
208
|
+
selectedEnvironmentIds: envIds,
|
|
209
|
+
})
|
|
210
|
+
: "unknown";
|
|
211
|
+
const overrideStatus = bulkRes.statuses[id]?.override?.status;
|
|
212
|
+
// rollupStatus is worst-wins over the public vocabulary, so the override
|
|
213
|
+
// lifts the status exactly as `applySystemHealthOverrides` does upstream.
|
|
214
|
+
out.set(
|
|
215
|
+
id,
|
|
216
|
+
overrideStatus
|
|
217
|
+
? rollupStatus([envChecks, mapHealthStatus(overrideStatus)])
|
|
218
|
+
: envChecks,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
91
223
|
const { statuses } = await ctx.rpcClient
|
|
92
224
|
.forPlugin(HealthCheckApi)
|
|
93
225
|
.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
226
|
for (const [systemId, value] of Object.entries(statuses)) {
|
|
102
|
-
|
|
227
|
+
if (value) out.set(systemId, mapHealthStatus(value.status));
|
|
103
228
|
}
|
|
104
|
-
return
|
|
229
|
+
return out;
|
|
105
230
|
}
|
|
106
231
|
|
|
107
232
|
function publicStatus({
|
|
@@ -110,12 +235,11 @@ function publicStatus({
|
|
|
110
235
|
maint,
|
|
111
236
|
}: {
|
|
112
237
|
systemId: string;
|
|
113
|
-
health:
|
|
238
|
+
health: Map<string, PublicStatus>;
|
|
114
239
|
maint: Set<string>;
|
|
115
240
|
}): PublicStatus {
|
|
116
241
|
if (maint.has(systemId)) return "maintenance";
|
|
117
|
-
|
|
118
|
-
return internal ? mapHealthStatus(internal) : "unknown";
|
|
242
|
+
return health.get(systemId) ?? "unknown";
|
|
119
243
|
}
|
|
120
244
|
|
|
121
245
|
/** assertBindingsReadable for system-bound widgets. */
|
|
@@ -146,12 +270,22 @@ const banner: WidgetTypeDefinition = {
|
|
|
146
270
|
assertBindingsReadable: assertSystems((c) => ({
|
|
147
271
|
systemIds: BannerConfigSchema.parse(c).systemIds,
|
|
148
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
|
+
},
|
|
149
281
|
async resolvePublic({ config, ctx }) {
|
|
150
282
|
const c = BannerConfigSchema.parse(config);
|
|
151
|
-
|
|
152
|
-
const
|
|
283
|
+
// Omit systems outside the page's published environments before rolling up.
|
|
284
|
+
const ids = await bannerScopedIds(config, ctx);
|
|
285
|
+
const health = await healthPublicStatuses(ctx, ids);
|
|
286
|
+
const maint = await inMaintenance(ctx, ids);
|
|
153
287
|
const status = overallBannerStatus(
|
|
154
|
-
|
|
288
|
+
ids.map((systemId) => publicStatus({ systemId, health, maint })),
|
|
155
289
|
);
|
|
156
290
|
return BannerDtoSchema.parse({
|
|
157
291
|
status,
|
|
@@ -176,14 +310,32 @@ const systemHealth: WidgetTypeDefinition = {
|
|
|
176
310
|
assertBindingsReadable: assertSystems((c) => ({
|
|
177
311
|
systemIds: SystemHealthConfigSchema.parse(c).items.map((i) => i.systemId),
|
|
178
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
|
+
},
|
|
179
329
|
async resolvePublic({ config, ctx }) {
|
|
180
330
|
const c = SystemHealthConfigSchema.parse(config);
|
|
181
|
-
|
|
182
|
-
const
|
|
331
|
+
// Drop rows for systems outside the page's published environments.
|
|
332
|
+
const items = await systemHealthScopedItems(config, ctx);
|
|
333
|
+
const ids = items.map((i) => i.systemId);
|
|
334
|
+
const health = await healthPublicStatuses(ctx, ids);
|
|
183
335
|
const maint = await inMaintenance(ctx, ids);
|
|
184
336
|
const names = await labelsFor(ctx, ids);
|
|
185
337
|
const uptime = c.showUptime ? await uptimeMap(ctx, ids) : undefined;
|
|
186
|
-
const systems =
|
|
338
|
+
const systems = items.map((item) => {
|
|
187
339
|
const pct = uptime?.get(item.systemId);
|
|
188
340
|
return {
|
|
189
341
|
label: item.label ?? names.get(item.systemId) ?? item.systemId,
|
|
@@ -199,22 +351,30 @@ async function uptimeMap(
|
|
|
199
351
|
ctx: WidgetResolveContext,
|
|
200
352
|
ids: string[],
|
|
201
353
|
): Promise<Map<string, number>> {
|
|
354
|
+
const out = new Map<string, number>();
|
|
355
|
+
if (ids.length === 0) return out;
|
|
202
356
|
const end = new Date();
|
|
203
357
|
const start = new Date(end.getTime() - 30 * 86_400_000);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
358
|
+
// ONE bulk call for every system's uptime, instead of an N+1 fan-out of
|
|
359
|
+
// per-system `getRunStats` (each holding a pooled connection). Systems with
|
|
360
|
+
// no runs are omitted from `stats`, so they never enter the map - preserving
|
|
361
|
+
// the previous "no runs => no misleading 0.00%" behavior exactly. When the
|
|
362
|
+
// page publishes a specific environment set, uptime counts only runs in those
|
|
363
|
+
// environments (env-less runs excluded).
|
|
364
|
+
const { stats } = await ctx.rpcClient
|
|
365
|
+
.forPlugin(HealthCheckApi)
|
|
366
|
+
.getBulkRunStats({
|
|
367
|
+
systemIds: ids,
|
|
368
|
+
startDate: start,
|
|
369
|
+
endDate: end,
|
|
370
|
+
...(ctx.publishedEnvironmentIds
|
|
371
|
+
? { environmentIds: ctx.publishedEnvironmentIds }
|
|
372
|
+
: {}),
|
|
373
|
+
maxBuckets: 1,
|
|
374
|
+
});
|
|
375
|
+
for (const systemId of ids) {
|
|
376
|
+
const s = stats[systemId];
|
|
377
|
+
if (s && s.total.runCount > 0) out.set(systemId, s.total.uptimePct);
|
|
218
378
|
}
|
|
219
379
|
return out;
|
|
220
380
|
}
|
|
@@ -233,12 +393,21 @@ const groupStatus: WidgetTypeDefinition = {
|
|
|
233
393
|
assertBindingsReadable: assertSystems((c) => ({
|
|
234
394
|
groupIds: [GroupStatusConfigSchema.parse(c).groupId],
|
|
235
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
|
+
},
|
|
236
404
|
async resolvePublic({ config, ctx }) {
|
|
237
405
|
const c = GroupStatusConfigSchema.parse(config);
|
|
238
406
|
const groups = await allGroups(ctx);
|
|
239
407
|
const group = groups.find((g) => g.id === c.groupId);
|
|
240
|
-
|
|
241
|
-
const
|
|
408
|
+
// Omit group members outside the page's published environments.
|
|
409
|
+
const ids = await groupStatusScopedIds(config, ctx);
|
|
410
|
+
const health = await healthPublicStatuses(ctx, ids);
|
|
242
411
|
const maint = await inMaintenance(ctx, ids);
|
|
243
412
|
const names = await labelsFor(ctx, ids);
|
|
244
413
|
const systems = ids.map((systemId) => ({
|
|
@@ -249,6 +418,7 @@ const groupStatus: WidgetTypeDefinition = {
|
|
|
249
418
|
label: c.label ?? group?.name ?? "Group",
|
|
250
419
|
status: rollupStatus(systems.map((s) => s.status)),
|
|
251
420
|
systems,
|
|
421
|
+
collapseWhenHealthy: c.collapseWhenHealthy,
|
|
252
422
|
});
|
|
253
423
|
},
|
|
254
424
|
};
|
|
@@ -267,19 +437,46 @@ const uptime: WidgetTypeDefinition = {
|
|
|
267
437
|
assertBindingsReadable: assertSystems((c) => ({
|
|
268
438
|
systemIds: [UptimeConfigSchema.parse(c).systemId],
|
|
269
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
|
+
},
|
|
270
450
|
async resolvePublic({ config, ctx }) {
|
|
271
451
|
const c = UptimeConfigSchema.parse(config);
|
|
452
|
+
const names = await labelsFor(ctx, [c.systemId]);
|
|
453
|
+
const label = c.label ?? names.get(c.systemId) ?? c.systemId;
|
|
454
|
+
// Omit this system's uptime when it is outside the page's published
|
|
455
|
+
// environments: emit the blank empty-state DTO (empty bars) the sibling
|
|
456
|
+
// health widgets use for out-of-scope systems, so the single-system uptime
|
|
457
|
+
// widget never shows misleading or stale-environment uptime. Gated on the
|
|
458
|
+
// CURRENT catalog env membership (`envVisibleSystems`), NOT on "getRunStats
|
|
459
|
+
// returned nothing" - so a system removed from the published env but still
|
|
460
|
+
// carrying old env-tagged runs is correctly blanked. No env filter (visible
|
|
461
|
+
// is null) leaves behavior unchanged.
|
|
462
|
+
const visible = await envVisibleSystems(ctx);
|
|
463
|
+
if (visible && !visible.has(c.systemId)) {
|
|
464
|
+
return UptimeDtoSchema.parse({ label, uptimePct: 0, bars: [] });
|
|
465
|
+
}
|
|
272
466
|
const end = new Date();
|
|
273
467
|
const start = new Date(end.getTime() - c.days * 86_400_000);
|
|
274
468
|
const stats = await ctx.rpcClient.forPlugin(HealthCheckApi).getRunStats({
|
|
275
469
|
systemId: c.systemId,
|
|
276
470
|
startDate: start,
|
|
277
471
|
endDate: end,
|
|
472
|
+
// Scope uptime to the page's published environments when set.
|
|
473
|
+
...(ctx.publishedEnvironmentIds
|
|
474
|
+
? { environmentIds: ctx.publishedEnvironmentIds }
|
|
475
|
+
: {}),
|
|
278
476
|
maxBuckets: c.days,
|
|
279
477
|
});
|
|
280
|
-
const names = await labelsFor(ctx, [c.systemId]);
|
|
281
478
|
return UptimeDtoSchema.parse({
|
|
282
|
-
label
|
|
479
|
+
label,
|
|
283
480
|
uptimePct: stats.total.uptimePct,
|
|
284
481
|
bars: stats.buckets.map((b) => ({
|
|
285
482
|
date: b.start,
|