diff --git a/src/app/api/admin/status-overview/__tests__/route.test.ts b/src/app/api/admin/status-overview/__tests__/route.test.ts index 059a88b18..373d62c6c 100644 --- a/src/app/api/admin/status-overview/__tests__/route.test.ts +++ b/src/app/api/admin/status-overview/__tests__/route.test.ts @@ -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(); diff --git a/src/app/api/admin/status-overview/route.ts b/src/app/api/admin/status-overview/route.ts index 151de63f6..493f6f699 100644 --- a/src/app/api/admin/status-overview/route.ts +++ b/src/app/api/admin/status-overview/route.ts @@ -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 = {}; + 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, },