@checkstack/healthcheck-backend 1.8.0 → 1.9.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 +271 -0
- package/package.json +29 -27
- package/src/ai/healthcheck-delete.test.ts +1 -1
- package/src/ai/healthcheck-delete.ts +1 -1
- package/src/ai-projections.test.ts +59 -0
- package/src/ai-projections.ts +59 -0
- package/src/index.ts +80 -7
- package/src/router.test.ts +2 -2
- package/src/router.ts +10 -6
- package/src/run-stats.logic.test.ts +73 -0
- package/src/run-stats.logic.ts +148 -0
- package/src/service.ts +63 -0
- package/src/status-page/rollup.test.ts +53 -0
- package/src/status-page/rollup.ts +82 -0
- package/src/status-page/widgets.ts +290 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { CatalogApi, assertCatalogResourcesReadable } from "@checkstack/catalog-common";
|
|
2
|
+
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
3
|
+
import {
|
|
4
|
+
pluginMetadata as statusPagePluginMetadata,
|
|
5
|
+
BannerConfigSchema,
|
|
6
|
+
BannerDtoSchema,
|
|
7
|
+
SystemHealthConfigSchema,
|
|
8
|
+
SystemHealthDtoSchema,
|
|
9
|
+
GroupStatusConfigSchema,
|
|
10
|
+
GroupStatusDtoSchema,
|
|
11
|
+
UptimeConfigSchema,
|
|
12
|
+
UptimeDtoSchema,
|
|
13
|
+
type PublicStatus,
|
|
14
|
+
} from "@checkstack/status-page-common";
|
|
15
|
+
import type {
|
|
16
|
+
WidgetResolveContext,
|
|
17
|
+
WidgetTypeDefinition,
|
|
18
|
+
StatusWidgetTypeExtensionPoint,
|
|
19
|
+
} from "@checkstack/status-page-backend";
|
|
20
|
+
import { HealthCheckApi } from "@checkstack/healthcheck-common";
|
|
21
|
+
import type { RpcClient } from "@checkstack/backend-api";
|
|
22
|
+
import {
|
|
23
|
+
mapHealthStatus,
|
|
24
|
+
rollupStatus,
|
|
25
|
+
overallBannerStatus,
|
|
26
|
+
statusBannerTitle,
|
|
27
|
+
} from "./rollup";
|
|
28
|
+
|
|
29
|
+
const SYSTEM_TYPE = "catalog.system";
|
|
30
|
+
const GROUP_TYPE = "catalog.group";
|
|
31
|
+
|
|
32
|
+
function uptimeToStatus(pct: number): PublicStatus {
|
|
33
|
+
if (pct >= 99.5) return "operational";
|
|
34
|
+
if (pct >= 95) return "degraded";
|
|
35
|
+
return "major_outage";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** All systems' id -> name, fetched once per page resolve. */
|
|
39
|
+
function systemNames(ctx: WidgetResolveContext): Promise<Map<string, string>> {
|
|
40
|
+
return ctx.cache("catalog.systemNames", async () => {
|
|
41
|
+
const { systems } = await ctx.rpcClient.forPlugin(CatalogApi).getSystems();
|
|
42
|
+
return new Map(systems.map((s) => [s.id, s.name]));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** All catalog groups, fetched once per page resolve. */
|
|
47
|
+
function allGroups(
|
|
48
|
+
ctx: WidgetResolveContext,
|
|
49
|
+
): Promise<Array<{ id: string; name: string; systemIds: string[] }>> {
|
|
50
|
+
return ctx.cache("catalog.groups", async () => {
|
|
51
|
+
const groups = await ctx.rpcClient.forPlugin(CatalogApi).getGroups();
|
|
52
|
+
return groups.map((g) => ({ id: g.id, name: g.name, systemIds: g.systemIds }));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function labelsFor(
|
|
57
|
+
ctx: WidgetResolveContext,
|
|
58
|
+
ids: string[],
|
|
59
|
+
): Promise<Map<string, string>> {
|
|
60
|
+
const all = await systemNames(ctx);
|
|
61
|
+
const out = new Map<string, string>();
|
|
62
|
+
for (const id of ids) {
|
|
63
|
+
const name = all.get(id);
|
|
64
|
+
if (name !== undefined) out.set(id, name);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function inMaintenance(
|
|
70
|
+
ctx: WidgetResolveContext,
|
|
71
|
+
ids: string[],
|
|
72
|
+
): Promise<Set<string>> {
|
|
73
|
+
if (ids.length === 0) return new Set();
|
|
74
|
+
const { maintenances } = await ctx.rpcClient
|
|
75
|
+
.forPlugin(MaintenanceApi)
|
|
76
|
+
.getBulkMaintenancesForSystems({ systemIds: ids });
|
|
77
|
+
const out = new Set<string>();
|
|
78
|
+
for (const id of ids) {
|
|
79
|
+
if ((maintenances[id] ?? []).some((m) => m.status === "in_progress")) {
|
|
80
|
+
out.add(id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function healthStatuses(
|
|
87
|
+
ctx: WidgetResolveContext,
|
|
88
|
+
ids: string[],
|
|
89
|
+
): Promise<Record<string, { status: string } | undefined>> {
|
|
90
|
+
if (ids.length === 0) return {};
|
|
91
|
+
const { statuses } = await ctx.rpcClient
|
|
92
|
+
.forPlugin(HealthCheckApi)
|
|
93
|
+
.getBulkSystemHealthStatus({ systemIds: ids });
|
|
94
|
+
return statuses;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function publicStatus({
|
|
98
|
+
systemId,
|
|
99
|
+
health,
|
|
100
|
+
maint,
|
|
101
|
+
}: {
|
|
102
|
+
systemId: string;
|
|
103
|
+
health: Record<string, { status: string } | undefined>;
|
|
104
|
+
maint: Set<string>;
|
|
105
|
+
}): PublicStatus {
|
|
106
|
+
if (maint.has(systemId)) return "maintenance";
|
|
107
|
+
const internal = health[systemId]?.status;
|
|
108
|
+
return internal ? mapHealthStatus(internal) : "unknown";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** assertBindingsReadable for system-bound widgets. */
|
|
112
|
+
const assertSystems =
|
|
113
|
+
(getIds: (config: unknown) => { systemIds?: string[]; groupIds?: string[] }) =>
|
|
114
|
+
async ({ userClient, config }: { userClient: RpcClient; config: unknown }) => {
|
|
115
|
+
const { systemIds, groupIds } = getIds(config);
|
|
116
|
+
await assertCatalogResourcesReadable({
|
|
117
|
+
client: userClient.forPlugin(CatalogApi),
|
|
118
|
+
systemIds,
|
|
119
|
+
groupIds,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const banner: WidgetTypeDefinition = {
|
|
124
|
+
id: "banner",
|
|
125
|
+
displayName: "Status banner",
|
|
126
|
+
description: "Overall status rolled up from the selected systems.",
|
|
127
|
+
category: "Status",
|
|
128
|
+
binding: "systems",
|
|
129
|
+
configSchema: BannerConfigSchema,
|
|
130
|
+
dtoSchema: BannerDtoSchema,
|
|
131
|
+
boundResources: (config) =>
|
|
132
|
+
BannerConfigSchema.parse(config).systemIds.map((id) => ({
|
|
133
|
+
resourceType: SYSTEM_TYPE,
|
|
134
|
+
resourceId: id,
|
|
135
|
+
})),
|
|
136
|
+
assertBindingsReadable: assertSystems((c) => ({
|
|
137
|
+
systemIds: BannerConfigSchema.parse(c).systemIds,
|
|
138
|
+
})),
|
|
139
|
+
async resolvePublic({ config, ctx }) {
|
|
140
|
+
const c = BannerConfigSchema.parse(config);
|
|
141
|
+
const health = await healthStatuses(ctx, c.systemIds);
|
|
142
|
+
const maint = await inMaintenance(ctx, c.systemIds);
|
|
143
|
+
const status = overallBannerStatus(
|
|
144
|
+
c.systemIds.map((systemId) => publicStatus({ systemId, health, maint })),
|
|
145
|
+
);
|
|
146
|
+
return BannerDtoSchema.parse({
|
|
147
|
+
status,
|
|
148
|
+
title: c.title ?? statusBannerTitle(status),
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const systemHealth: WidgetTypeDefinition = {
|
|
154
|
+
id: "systemHealth",
|
|
155
|
+
displayName: "System health",
|
|
156
|
+
description: "A status row per selected system.",
|
|
157
|
+
category: "Status",
|
|
158
|
+
binding: "systems",
|
|
159
|
+
configSchema: SystemHealthConfigSchema,
|
|
160
|
+
dtoSchema: SystemHealthDtoSchema,
|
|
161
|
+
boundResources: (config) =>
|
|
162
|
+
SystemHealthConfigSchema.parse(config).items.map((i) => ({
|
|
163
|
+
resourceType: SYSTEM_TYPE,
|
|
164
|
+
resourceId: i.systemId,
|
|
165
|
+
})),
|
|
166
|
+
assertBindingsReadable: assertSystems((c) => ({
|
|
167
|
+
systemIds: SystemHealthConfigSchema.parse(c).items.map((i) => i.systemId),
|
|
168
|
+
})),
|
|
169
|
+
async resolvePublic({ config, ctx }) {
|
|
170
|
+
const c = SystemHealthConfigSchema.parse(config);
|
|
171
|
+
const ids = c.items.map((i) => i.systemId);
|
|
172
|
+
const health = await healthStatuses(ctx, ids);
|
|
173
|
+
const maint = await inMaintenance(ctx, ids);
|
|
174
|
+
const names = await labelsFor(ctx, ids);
|
|
175
|
+
const uptime = c.showUptime ? await uptimeMap(ctx, ids) : undefined;
|
|
176
|
+
const systems = c.items.map((item) => {
|
|
177
|
+
const pct = uptime?.get(item.systemId);
|
|
178
|
+
return {
|
|
179
|
+
label: item.label ?? names.get(item.systemId) ?? item.systemId,
|
|
180
|
+
status: publicStatus({ systemId: item.systemId, health, maint }),
|
|
181
|
+
...(pct === undefined ? {} : { uptimePct: pct }),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
return SystemHealthDtoSchema.parse({ systems });
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
async function uptimeMap(
|
|
189
|
+
ctx: WidgetResolveContext,
|
|
190
|
+
ids: string[],
|
|
191
|
+
): Promise<Map<string, number>> {
|
|
192
|
+
const end = new Date();
|
|
193
|
+
const start = new Date(end.getTime() - 30 * 86_400_000);
|
|
194
|
+
const out = new Map<string, number>();
|
|
195
|
+
const results = await Promise.allSettled(
|
|
196
|
+
ids.map(async (systemId) => {
|
|
197
|
+
const stats = await ctx.rpcClient
|
|
198
|
+
.forPlugin(HealthCheckApi)
|
|
199
|
+
.getRunStats({ systemId, startDate: start, endDate: end, maxBuckets: 1 });
|
|
200
|
+
// No runs in the window => no uptime to report (don't surface a
|
|
201
|
+
// misleading 0.00% for a system with no history).
|
|
202
|
+
if (stats.total.runCount === 0) return null;
|
|
203
|
+
return [systemId, stats.total.uptimePct] as const;
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
for (const r of results) {
|
|
207
|
+
if (r.status === "fulfilled" && r.value) out.set(r.value[0], r.value[1]);
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const groupStatus: WidgetTypeDefinition = {
|
|
213
|
+
id: "groupStatus",
|
|
214
|
+
displayName: "Group status",
|
|
215
|
+
description: "Rolled-up status of every system in a catalog group.",
|
|
216
|
+
category: "Status",
|
|
217
|
+
binding: "group",
|
|
218
|
+
configSchema: GroupStatusConfigSchema,
|
|
219
|
+
dtoSchema: GroupStatusDtoSchema,
|
|
220
|
+
boundResources: (config) => [
|
|
221
|
+
{ resourceType: GROUP_TYPE, resourceId: GroupStatusConfigSchema.parse(config).groupId },
|
|
222
|
+
],
|
|
223
|
+
assertBindingsReadable: assertSystems((c) => ({
|
|
224
|
+
groupIds: [GroupStatusConfigSchema.parse(c).groupId],
|
|
225
|
+
})),
|
|
226
|
+
async resolvePublic({ config, ctx }) {
|
|
227
|
+
const c = GroupStatusConfigSchema.parse(config);
|
|
228
|
+
const groups = await allGroups(ctx);
|
|
229
|
+
const group = groups.find((g) => g.id === c.groupId);
|
|
230
|
+
const ids = group?.systemIds ?? [];
|
|
231
|
+
const health = await healthStatuses(ctx, ids);
|
|
232
|
+
const maint = await inMaintenance(ctx, ids);
|
|
233
|
+
const names = await labelsFor(ctx, ids);
|
|
234
|
+
const systems = ids.map((systemId) => ({
|
|
235
|
+
label: names.get(systemId) ?? systemId,
|
|
236
|
+
status: publicStatus({ systemId, health, maint }),
|
|
237
|
+
}));
|
|
238
|
+
return GroupStatusDtoSchema.parse({
|
|
239
|
+
label: c.label ?? group?.name ?? "Group",
|
|
240
|
+
status: rollupStatus(systems.map((s) => s.status)),
|
|
241
|
+
systems,
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const uptime: WidgetTypeDefinition = {
|
|
247
|
+
id: "uptime",
|
|
248
|
+
displayName: "Uptime history",
|
|
249
|
+
description: "A daily uptime bar chart for one system.",
|
|
250
|
+
category: "Status",
|
|
251
|
+
binding: "system",
|
|
252
|
+
configSchema: UptimeConfigSchema,
|
|
253
|
+
dtoSchema: UptimeDtoSchema,
|
|
254
|
+
boundResources: (config) => [
|
|
255
|
+
{ resourceType: SYSTEM_TYPE, resourceId: UptimeConfigSchema.parse(config).systemId },
|
|
256
|
+
],
|
|
257
|
+
assertBindingsReadable: assertSystems((c) => ({
|
|
258
|
+
systemIds: [UptimeConfigSchema.parse(c).systemId],
|
|
259
|
+
})),
|
|
260
|
+
async resolvePublic({ config, ctx }) {
|
|
261
|
+
const c = UptimeConfigSchema.parse(config);
|
|
262
|
+
const end = new Date();
|
|
263
|
+
const start = new Date(end.getTime() - c.days * 86_400_000);
|
|
264
|
+
const stats = await ctx.rpcClient.forPlugin(HealthCheckApi).getRunStats({
|
|
265
|
+
systemId: c.systemId,
|
|
266
|
+
startDate: start,
|
|
267
|
+
endDate: end,
|
|
268
|
+
maxBuckets: c.days,
|
|
269
|
+
});
|
|
270
|
+
const names = await labelsFor(ctx, [c.systemId]);
|
|
271
|
+
return UptimeDtoSchema.parse({
|
|
272
|
+
label: c.label ?? names.get(c.systemId) ?? c.systemId,
|
|
273
|
+
uptimePct: stats.total.uptimePct,
|
|
274
|
+
bars: stats.buckets.map((b) => ({
|
|
275
|
+
date: b.start,
|
|
276
|
+
uptimePct: b.uptimePct,
|
|
277
|
+
status: uptimeToStatus(b.uptimePct),
|
|
278
|
+
})),
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Register the health-owned status-page widgets under the `statuspage.*` namespace. */
|
|
284
|
+
export function registerHealthcheckStatusWidgets(
|
|
285
|
+
ext: StatusWidgetTypeExtensionPoint,
|
|
286
|
+
): void {
|
|
287
|
+
for (const w of [banner, systemHealth, groupStatus, uptime]) {
|
|
288
|
+
ext.registerWidgetType(w, statusPagePluginMetadata);
|
|
289
|
+
}
|
|
290
|
+
}
|