219 lines
6.1 KiB
TypeScript
219 lines
6.1 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { http } from "../api/http";
|
|
|
|
type AppHealth = { ok: true };
|
|
type DbHealth = { ok: true; nowISO: string; latencyMs: number };
|
|
|
|
export default function HealthPage() {
|
|
const app = useQuery({
|
|
queryKey: ["health"],
|
|
queryFn: () => http<AppHealth>("/health"),
|
|
});
|
|
|
|
const db = useQuery({
|
|
queryKey: ["health", "db"],
|
|
queryFn: () => http<DbHealth>("/health/db"),
|
|
});
|
|
|
|
const appStatus = getStatus(app.isLoading, !!app.data?.ok, !!app.error);
|
|
const dbStatus = getStatus(db.isLoading, !!db.data?.ok, !!db.error);
|
|
|
|
return (
|
|
<div className="flex justify-center py-10">
|
|
<div className="card w-full max-w-xl space-y-6">
|
|
<header className="space-y-1">
|
|
<h2 className="section-title">System Health</h2>
|
|
<p className="muted text-sm">
|
|
Quick status for the SkyMoney API and database. Use this when debugging issues or latency.
|
|
</p>
|
|
</header>
|
|
|
|
{/* Status overview */}
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<HealthCard
|
|
label="API"
|
|
status={appStatus}
|
|
description="Core application and auth endpoints."
|
|
details={
|
|
<>
|
|
<StatusLine
|
|
label="Status"
|
|
value={labelForStatus(appStatus)}
|
|
/>
|
|
{app.error && (
|
|
<StatusLine
|
|
label="Error"
|
|
value={
|
|
app.error instanceof Error
|
|
? app.error.message
|
|
: "Unknown error"
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
onRetry={!app.isLoading ? () => app.refetch() : undefined}
|
|
/>
|
|
|
|
<HealthCard
|
|
label="Database"
|
|
status={dbStatus}
|
|
description="Primary PostgreSQL connection and latency."
|
|
details={
|
|
<>
|
|
<StatusLine
|
|
label="Status"
|
|
value={labelForStatus(dbStatus)}
|
|
/>
|
|
<StatusLine
|
|
label="Latency"
|
|
value={
|
|
db.data?.latencyMs != null
|
|
? `${db.data.latencyMs} ms`
|
|
: db.isLoading
|
|
? "Measuring…"
|
|
: "—"
|
|
}
|
|
/>
|
|
<StatusLine
|
|
label="Server time"
|
|
value={
|
|
db.data?.nowISO
|
|
? new Date(db.data.nowISO).toLocaleString()
|
|
: db.isLoading
|
|
? "Loading…"
|
|
: "—"
|
|
}
|
|
/>
|
|
{db.error && (
|
|
<StatusLine
|
|
label="Error"
|
|
value={
|
|
db.error instanceof Error
|
|
? db.error.message
|
|
: "Unknown error"
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
onRetry={!db.isLoading ? () => db.refetch() : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{/* Raw data (tiny, for debugging) */}
|
|
<section className="border rounded-xl p-3 space-y-2 bg-[--color-panel]">
|
|
<div className="row items-center">
|
|
<span className="text-xs uppercase tracking-[0.2em] muted">
|
|
Debug
|
|
</span>
|
|
<span className="ml-auto text-xs muted">
|
|
Useful for logs / screenshots
|
|
</span>
|
|
</div>
|
|
<pre className="text-xs whitespace-pre-wrap break-all muted">
|
|
API: {app.isLoading ? "Loading…" : JSON.stringify(app.data ?? { ok: false })}{"\n"}
|
|
DB: {db.isLoading ? "Loading…" : JSON.stringify(db.data ?? { ok: false })}
|
|
</pre>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type Status = "checking" | "up" | "down";
|
|
|
|
function getStatus(
|
|
isLoading: boolean,
|
|
ok: boolean,
|
|
hasError: boolean,
|
|
): Status {
|
|
if (isLoading) return "checking";
|
|
if (ok && !hasError) return "up";
|
|
return "down";
|
|
}
|
|
|
|
function labelForStatus(status: Status) {
|
|
if (status === "checking") return "Checking…";
|
|
if (status === "up") return "Operational";
|
|
return "Unavailable";
|
|
}
|
|
|
|
function HealthCard({
|
|
label,
|
|
status,
|
|
description,
|
|
details,
|
|
onRetry,
|
|
}: {
|
|
label: string;
|
|
status: Status;
|
|
description: string;
|
|
details: React.ReactNode;
|
|
onRetry?: () => void;
|
|
}) {
|
|
return (
|
|
<section className="border rounded-xl p-4 bg-[--color-panel] space-y-3">
|
|
<div className="row items-center gap-2">
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-semibold">{label}</div>
|
|
<p className="text-xs muted">{description}</p>
|
|
</div>
|
|
<StatusPill status={status} className="ml-auto" />
|
|
</div>
|
|
|
|
<div className="space-y-1 text-xs">{details}</div>
|
|
|
|
{onRetry && (
|
|
<div className="row mt-2">
|
|
<button type="button" className="btn text-xs ml-auto" onClick={onRetry}>
|
|
Recheck
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function StatusPill({
|
|
status,
|
|
className = "",
|
|
}: {
|
|
status: Status;
|
|
className?: string;
|
|
}) {
|
|
let text = "";
|
|
let tone = "";
|
|
|
|
switch (status) {
|
|
case "checking":
|
|
text = "Checking…";
|
|
tone = "bg-amber-500/10 text-amber-100 border border-amber-500/40";
|
|
break;
|
|
case "up":
|
|
text = "OK";
|
|
tone = "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40";
|
|
break;
|
|
case "down":
|
|
text = "Down";
|
|
tone = "bg-red-500/10 text-red-100 border border-red-500/40";
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<span className={`badge text-xs ${tone} ${className}`}>
|
|
<span className="inline-block w-2 h-2 rounded-full mr-1 bg-current" />
|
|
{text}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatusLine({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="row text-xs">
|
|
<span className="muted">{label}</span>
|
|
<span className="ml-auto text-right">{value}</span>
|
|
</div>
|
|
);
|
|
}
|