feat: implement system status API with service health checks and timeout handling

This commit is contained in:
2026-01-29 14:06:23 +02:00
parent b0b54e2e60
commit 3fda62eb47
2 changed files with 181 additions and 39 deletions

View File

@@ -0,0 +1,116 @@
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
import { NextRequest, NextResponse } from "next/server";
import { verifyAdminSession } from "@/lib/supabase-auth";
import { prisma } from "@/lib/db";
import { getSecurityGateSecretInfo } from "@/lib/security-gate-secret";
import { getRequestId } from "@/lib/request-id";
import { logger } from "@/lib/logger";
type ServiceState = "ok" | "degraded" | "down";
interface ServiceStatus {
status: ServiceState;
label: string;
latencyMs: number;
checkedAt: string;
details?: string;
}
const TIMEOUT_MS = 2000;
async function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`${label} timeout`)), TIMEOUT_MS);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
async function checkService(
label: string,
okLabel: string,
fn: () => Promise<void>
): Promise<ServiceStatus> {
const start = Date.now();
try {
await withTimeout(fn(), label);
return {
status: "ok",
label: okLabel,
latencyMs: Date.now() - start,
checkedAt: new Date().toISOString(),
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
const isTimeout = message.toLowerCase().includes("timeout");
return {
status: isTimeout ? "degraded" : "down",
label: isTimeout ? "Degraded" : "Issues",
latencyMs: Date.now() - start,
checkedAt: new Date().toISOString(),
details: message,
};
}
}
export async function GET(request: NextRequest) {
const requestId = getRequestId(request);
try {
const session = await verifyAdminSession(request);
if (!session || !session.authenticated) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const [website, rsvp, photos, music, guestbook, gate] = await Promise.all([
checkService("website", "Online", async () => {
await prisma.$queryRaw`SELECT 1`;
}),
checkService("rsvp", "Online", async () => {
await prisma.guest.count();
}),
checkService("photos", "Online", async () => {
await prisma.galleryPhoto.count();
}),
checkService("music", "Online", async () => {
await prisma.song.count();
}),
checkService("guestbook", "Online", async () => {
await prisma.guestbookEntry.count();
}),
checkService("gate", "Online", async () => {
await getSecurityGateSecretInfo();
}),
]);
const response = {
checkedAt: new Date().toISOString(),
services: {
website,
rsvp,
photos,
music,
guestbook,
gate,
},
};
return NextResponse.json(response, {
headers: {
"Cache-Control": "no-store, max-age=0",
},
});
} catch (error) {
logger.error("Admin system status error", error instanceof Error ? error : new Error(String(error)), {
requestId,
tags: { route: "admin/system-status" },
});
return NextResponse.json({ error: "Failed to fetch system status" }, { status: 500 });
}
}

View File

@@ -50,12 +50,12 @@ export function OverviewTab({
});
const statusConfig = useMemo(() => ([
{ id: "website", label: "Website", endpoint: "/api/health", okLabel: "Online" },
{ id: "rsvp", label: "RSVP System", endpoint: "/api/admin/rsvp", okLabel: "Active" },
{ id: "photos", label: "Photo Upload", endpoint: "/api/admin/gallery", okLabel: "Operational" },
{ id: "music", label: "Music Requests", endpoint: "/api/admin/music", okLabel: "Operational" },
{ id: "guestbook", label: "Guestbook", endpoint: "/api/admin/guestbook", okLabel: "Operational" },
{ id: "gate", label: "Security Gate", endpoint: "/api/admin/security-gate/secret", okLabel: "Online" },
{ id: "website", label: "Website", okLabel: "Online" },
{ id: "rsvp", label: "RSVP System", okLabel: "Online" },
{ id: "photos", label: "Photo Upload", okLabel: "Online" },
{ id: "music", label: "Music Requests", okLabel: "Online" },
{ id: "guestbook", label: "Guestbook", okLabel: "Online" },
{ id: "gate", label: "Security Gate", okLabel: "Online" },
] as const), []);
const checkSystemStatus = useCallback(async (setChecking = false) => {
@@ -69,41 +69,60 @@ export function OverviewTab({
});
}
await Promise.all(
statusConfig.map(async (item) => {
try {
const response = await fetch(item.endpoint, {
method: "GET",
credentials: "include",
cache: "no-store",
try {
const response = await fetch("/api/admin/system-status", {
method: "GET",
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
const label = response.status === 401 ? "Auth required" : "Service error";
setSystemStatus((prev) => {
const next = { ...prev };
statusConfig.forEach((item) => {
next[item.id] = {
state: response.status === 401 ? "degraded" : "error",
label,
};
});
return next;
});
return;
}
if (response.ok) {
setSystemStatus((prev) => ({
...prev,
[item.id]: { state: "ok", label: item.okLabel },
}));
return;
const data = await response.json();
const services = data?.services || {};
setSystemStatus((prev) => {
const next = { ...prev };
statusConfig.forEach((item) => {
const service = services[item.id];
if (service && service.status) {
const state = service.status === "ok"
? "ok"
: service.status === "degraded"
? "degraded"
: "error";
next[item.id] = {
state,
label: service.label || (state === "ok" ? item.okLabel : "Degraded"),
};
} else {
next[item.id] = { state: "degraded", label: "Unknown" };
}
const label = response.status === 401
? "Auth required"
: response.status >= 500
? "Service error"
: `Degraded (${response.status})`;
setSystemStatus((prev) => ({
...prev,
[item.id]: { state: response.status >= 500 ? "error" : "degraded", label },
}));
} catch (error) {
setSystemStatus((prev) => ({
...prev,
[item.id]: { state: "error", label: "Offline" },
}));
}
})
);
});
return next;
});
} catch (error) {
setSystemStatus((prev) => {
const next = { ...prev };
statusConfig.forEach((item) => {
next[item.id] = { state: "error", label: "Offline" };
});
return next;
});
}
}, [statusConfig]);
const handleRefresh = useCallback(async () => {
@@ -309,13 +328,20 @@ export function OverviewTab({
: status.state === "error"
? "text-red-600"
: "text-gray-500";
const resolvedLabel = status.state === "ok"
? "Online"
: status.state === "degraded"
? "Degraded"
: status.state === "error"
? "Issues"
: "Checking";
return (
<div key={item.id} className="flex items-center justify-between">
<span className="text-sm font-body">{item.label}</span>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${dotClass}`}></div>
<span className={`text-xs ${textClass}`}>{status.label}</span>
<span className={`text-xs ${textClass}`}>{resolvedLabel}</span>
</div>
</div>
);