fix(admin/status-overview): one failed probe no longer blanks the grid

P20. Switch the 16-probe batch from `Promise.all` to
`Promise.allSettled`. Before: any single probe rejecting (a transient
DB timeout, a missing index after a schema migration, etc.) would
reject the whole route and the admin overview grid would render as
blank — costing the admin the other five healthy cards' info too.

Now each probe is labelled, the rejected ones map their card's severity
to "alert" + zero/null defaults so the StatusCardGrid still draws
something, and the failed probe names are surfaced in a Wide Event
annotation (`statusOverviewProbeFailures: [...]`) for ops triage.

Adds a vitest case that mocks a single rejection on the withings probe
and asserts the response still 200s, the integrations card flips to
alert with `withings: 0`, and untouched cards keep their normal
severity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 02:10:15 +02:00
1 parent 26543378b5
commit dc4507a55f
2 files changed
+190 -70

No files matched your search

@@ -88,7 +88,7 @@ describe("GET /api/admin/status-overview", () => {
expect(body.data).toHaveProperty("auditLog");
});
it("batches DB calls in a single Promise.all (no N+1)", async () => {
it("batches DB calls in a single Promise.allSettled (no N+1)", async () => {
await GET();
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
// moodLog/telegram done via user.count → expect user.count called 4 times
@@ -101,6 +101,30 @@ describe("GET /api/admin/status-overview", () => {
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
});
it("P20: a single failed probe still returns the rest of the grid", async () => {
// Simulate a transient DB failure on the withings count probe only.
// Without Promise.allSettled the whole route would 500 and the entire
// admin overview grid would go blank — with allSettled the failure
// is isolated to its card (severity → "alert", value → 0) and the
// other 5 cards still render.
mocks.withingsConnection.count.mockRejectedValueOnce(
new Error("simulated db timeout"),
);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
users: { severity: string };
integrations: { severity: string; withings: number };
backups: { severity: string };
};
};
expect(body.data.integrations.severity).toBe("alert");
expect(body.data.integrations.withings).toBe(0);
// Untouched cards keep their normal severity.
expect(body.data.users.severity).toBe("good");
});
it("flags backups as alert when none exist", async () => {
mocks.dataBackup.findFirst.mockResolvedValue(null);
const res = await GET();
+165 -69
View File
@@ -93,65 +93,138 @@ export const GET = apiHandler(async () => {
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
// Single batched roundtrip — no N+1.
const [
userTotal,
userAdmins,
userNewThisWeek,
withingsCount,
moodLogCount,
telegramCount,
ntfyCount,
webPushCount,
appSettings,
lastErrorEntry,
latestBackup,
backedUpUsers,
last30dEvents,
lastLoginEntry,
lastIdempotencyCleanup,
lastAuditLogCleanup,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { role: "ADMIN" } }),
prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
prisma.withingsConnection.count(),
prisma.user.count({ where: { moodLogEnabled: true } }),
prisma.user.count({ where: { telegramEnabled: true } }),
prisma.notificationChannel.count({
where: { type: "NTFY", enabled: true },
}),
prisma.pushSubscription.count(),
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
prisma.auditLog.findFirst({
where: { action: { startsWith: "system.error" } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
prisma.dataBackup.findFirst({
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
prisma.dataBackup
.findMany({ select: { userId: true }, distinct: ["userId"] })
.then((rows) => rows.length),
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
prisma.auditLog.findFirst({
where: { action: "auth.login" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
prisma.auditLog.findFirst({
where: { action: "system.cleanup.idempotency" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
prisma.auditLog.findFirst({
where: { action: "system.cleanup.audit-log" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
]);
// P20: switch from Promise.all to Promise.allSettled so a single
// failed probe (e.g. a missing index, a transient timeout, an
// unavailable model) doesn't blank the whole admin overview grid.
// Each probe is labelled so we can map a rejection back to the
// overview field it was meant to populate, then surface the failed
// labels in a Wide Event annotation for ops triage.
const probes = [
["userTotal", () => prisma.user.count()],
["userAdmins", () => prisma.user.count({ where: { role: "ADMIN" } })],
[
"userNewThisWeek",
() => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
],
["withingsCount", () => prisma.withingsConnection.count()],
["moodLogCount", () => prisma.user.count({ where: { moodLogEnabled: true } })],
[
"telegramCount",
() => prisma.user.count({ where: { telegramEnabled: true } }),
],
[
"ntfyCount",
() =>
prisma.notificationChannel.count({
where: { type: "NTFY", enabled: true },
}),
],
["webPushCount", () => prisma.pushSubscription.count()],
[
"appSettings",
() => prisma.appSettings.findUnique({ where: { id: "singleton" } }),
],
[
"lastErrorEntry",
() =>
prisma.auditLog.findFirst({
where: { action: { startsWith: "system.error" } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"latestBackup",
() =>
prisma.dataBackup.findFirst({
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"backedUpUsers",
() =>
prisma.dataBackup
.findMany({ select: { userId: true }, distinct: ["userId"] })
.then((rows) => rows.length),
],
[
"last30dEvents",
() =>
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
],
[
"lastLoginEntry",
() =>
prisma.auditLog.findFirst({
where: { action: "auth.login" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"lastIdempotencyCleanup",
() =>
prisma.auditLog.findFirst({
where: { action: "system.cleanup.idempotency" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"lastAuditLogCleanup",
() =>
prisma.auditLog.findFirst({
where: { action: "system.cleanup.audit-log" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
] as const;
const settled = await Promise.allSettled(probes.map(([, run]) => run()));
const failed: string[] = [];
const results: Record<string, unknown> = {};
settled.forEach((r, i) => {
const label = probes[i][0];
if (r.status === "fulfilled") {
results[label] = r.value;
} else {
failed.push(label);
results[label] = null;
}
});
// Pull each probe out in the same shape the overview expects. `?? 0`
// turns a null (failed-count probe) into a renderable zero so the
// grid tiles still draw something instead of going blank.
const userTotal = (results.userTotal as number | null) ?? 0;
const userAdmins = (results.userAdmins as number | null) ?? 0;
const userNewThisWeek = (results.userNewThisWeek as number | null) ?? 0;
const withingsCount = (results.withingsCount as number | null) ?? 0;
const moodLogCount = (results.moodLogCount as number | null) ?? 0;
const telegramCount = (results.telegramCount as number | null) ?? 0;
const ntfyCount = (results.ntfyCount as number | null) ?? 0;
const webPushCount = (results.webPushCount as number | null) ?? 0;
const appSettings = results.appSettings as
| { glitchtipEnabled: boolean; umamiEnabled: boolean }
| null;
const lastErrorEntry = results.lastErrorEntry as { createdAt: Date } | null;
const latestBackup = results.latestBackup as { createdAt: Date } | null;
const backedUpUsers = (results.backedUpUsers as number | null) ?? 0;
const last30dEvents = (results.last30dEvents as number | null) ?? 0;
const lastLoginEntry = results.lastLoginEntry as { createdAt: Date } | null;
const lastIdempotencyCleanup = results.lastIdempotencyCleanup as
| { createdAt: Date }
| null;
const lastAuditLogCleanup = results.lastAuditLogCleanup as
| { createdAt: Date }
| null;
if (failed.length > 0) {
annotate({ meta: { statusOverviewProbeFailures: failed } });
}
const worker = getWorkerStatus();
const workerUptimeSeconds = worker.startedAt
@@ -166,15 +239,32 @@ export const GET = apiHandler(async () => {
const integrationsTotal =
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
// Map probe-name → which overview card depends on it. If any probe
// for a card failed, force the card's severity to "alert" so the UI
// surfaces the failure instead of showing zeros as if they were real.
const failedSet = new Set(failed);
const anyFailed = (...labels: string[]) =>
labels.some((l) => failedSet.has(l));
const overview: StatusOverview = {
users: {
severity: "good",
severity: anyFailed("userTotal", "userAdmins", "userNewThisWeek")
? "alert"
: "good",
total: userTotal,
admins: userAdmins,
newThisWeek: userNewThisWeek,
},
integrations: {
severity: severityForIntegrations(integrationsTotal),
severity: anyFailed(
"withingsCount",
"moodLogCount",
"telegramCount",
"ntfyCount",
"webPushCount",
)
? "alert"
: severityForIntegrations(integrationsTotal),
withings: withingsCount,
moodLog: moodLogCount,
telegram: telegramCount,
@@ -182,24 +272,30 @@ export const GET = apiHandler(async () => {
webPush: webPushCount,
},
monitoring: {
severity: severityForMonitoring(
Boolean(appSettings?.glitchtipEnabled),
Boolean(appSettings?.umamiEnabled),
lastErrorEntry?.createdAt ?? null,
),
severity: anyFailed("appSettings", "lastErrorEntry")
? "alert"
: severityForMonitoring(
Boolean(appSettings?.glitchtipEnabled),
Boolean(appSettings?.umamiEnabled),
lastErrorEntry?.createdAt ?? null,
),
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
umamiEnabled: Boolean(appSettings?.umamiEnabled),
wideEventsEnabled: true,
lastErrorAt: lastErrorEntry?.createdAt?.toISOString() ?? null,
},
backups: {
severity: severityForBackup(latestBackup?.createdAt ?? null),
severity: anyFailed("latestBackup", "backedUpUsers")
? "alert"
: severityForBackup(latestBackup?.createdAt ?? null),
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
backedUpUsers,
retentionDays: 90,
},
maintenance: {
severity: severityForMaintenance(worker.running),
severity: anyFailed("lastIdempotencyCleanup", "lastAuditLogCleanup")
? "alert"
: severityForMaintenance(worker.running),
workerRunning: worker.running,
workerUptimeSeconds,
lastIdempotencyCleanup:
@@ -207,7 +303,7 @@ export const GET = apiHandler(async () => {
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
},
auditLog: {
severity: "info",
severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "info",
eventsLast30d: last30dEvents,
lastLoginAt: lastLoginEntry?.createdAt?.toISOString() ?? null,
},