feat: implement system status API with service health checks and timeout handling
This commit is contained in:
116
src/app/api/admin/system-status/route.ts
Normal file
116
src/app/api/admin/system-status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user