140 lines
4.1 KiB
TypeScript
140 lines
4.1 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { cn } from '@/lib/cn';
|
||
|
|
|
||
|
|
interface Probe {
|
||
|
|
name: string;
|
||
|
|
url: string;
|
||
|
|
description: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
const PROBES: Probe[] = [
|
||
|
|
{
|
||
|
|
name: 'Dashboard',
|
||
|
|
url: '/login',
|
||
|
|
description: 'Web frontend (Next.js)',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'Control plane API',
|
||
|
|
url: '/api/health',
|
||
|
|
description: 'Fastify control plane via /api proxy',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
type State = 'pending' | 'ok' | 'down';
|
||
|
|
|
||
|
|
interface Result {
|
||
|
|
state: State;
|
||
|
|
latencyMs: number | null;
|
||
|
|
detail: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function Status() {
|
||
|
|
const [results, setResults] = useState<Record<string, Result>>(() =>
|
||
|
|
Object.fromEntries(PROBES.map((p) => [p.name, { state: 'pending', latencyMs: null, detail: null }])),
|
||
|
|
);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let cancelled = false;
|
||
|
|
async function probe() {
|
||
|
|
const next: Record<string, Result> = {};
|
||
|
|
for (const p of PROBES) {
|
||
|
|
const start = performance.now();
|
||
|
|
try {
|
||
|
|
const res = await fetch(p.url, { cache: 'no-store' });
|
||
|
|
const latency = Math.round(performance.now() - start);
|
||
|
|
next[p.name] = {
|
||
|
|
state: res.ok ? 'ok' : 'down',
|
||
|
|
latencyMs: latency,
|
||
|
|
detail: res.ok ? `HTTP ${res.status}` : `HTTP ${res.status}`,
|
||
|
|
};
|
||
|
|
} catch (e) {
|
||
|
|
next[p.name] = {
|
||
|
|
state: 'down',
|
||
|
|
latencyMs: null,
|
||
|
|
detail: (e as Error).message,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!cancelled) setResults(next);
|
||
|
|
}
|
||
|
|
probe();
|
||
|
|
const interval = setInterval(probe, 10_000);
|
||
|
|
return () => {
|
||
|
|
cancelled = true;
|
||
|
|
clearInterval(interval);
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const anyDown = Object.values(results).some((r) => r.state === 'down');
|
||
|
|
const allPending = Object.values(results).every((r) => r.state === 'pending');
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-3xl px-6 py-16">
|
||
|
|
<header className="mb-10">
|
||
|
|
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||
|
|
System status
|
||
|
|
</div>
|
||
|
|
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">
|
||
|
|
{allPending ? 'Checking…' : anyDown ? 'Partial outage' : 'All systems operational'}
|
||
|
|
</h1>
|
||
|
|
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||
|
|
Live health probes against each service. Refreshes every 10 seconds.
|
||
|
|
</p>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="panel divide-y divide-[--color-border]">
|
||
|
|
{PROBES.map((p) => {
|
||
|
|
const r = results[p.name]!;
|
||
|
|
return (
|
||
|
|
<div key={p.name} className="flex items-center justify-between px-4 py-3">
|
||
|
|
<div>
|
||
|
|
<div className="text-[13.5px] font-medium">{p.name}</div>
|
||
|
|
<div className="text-[12px] text-[--color-fg-subtle]">{p.description}</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-3 text-[12px]">
|
||
|
|
{r.latencyMs !== null && (
|
||
|
|
<span className="mono text-[--color-fg-subtle]">{r.latencyMs}ms</span>
|
||
|
|
)}
|
||
|
|
<Dot state={r.state} />
|
||
|
|
<span className={cn(stateClass(r.state), 'font-medium tabular-nums')}>
|
||
|
|
{stateLabel(r.state)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
||
|
|
Production status board: BetterStack-hosted at status.buildmymcpserver.com (set up
|
||
|
|
post-launch).
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function Dot({ state }: { state: State }) {
|
||
|
|
const color =
|
||
|
|
state === 'ok' ? 'bg-emerald-400' : state === 'down' ? 'bg-red-400' : 'bg-zinc-400';
|
||
|
|
return (
|
||
|
|
<span
|
||
|
|
className={cn('size-2 rounded-full', color)}
|
||
|
|
style={state === 'ok' ? { animation: 'pulse-dot 1.6s ease-in-out infinite' } : undefined}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function stateLabel(state: State): string {
|
||
|
|
if (state === 'ok') return 'Operational';
|
||
|
|
if (state === 'down') return 'Down';
|
||
|
|
return 'Checking';
|
||
|
|
}
|
||
|
|
|
||
|
|
function stateClass(state: State): string {
|
||
|
|
if (state === 'ok') return 'text-emerald-300';
|
||
|
|
if (state === 'down') return 'text-red-300';
|
||
|
|
return 'text-[--color-fg-subtle]';
|
||
|
|
}
|