health-client.tsx337 lines · main
1'use client';
2
3import { useCallback, useEffect, useState } from 'react';
4
5import { ActivityIcon } from '@/components/ui/activity';
6import { DatabaseIcon } from '@/components/ui/database';
7import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
8import { ZapIcon } from '@/components/ui/zap';
9
10import { EmptyState, EmptyStateButton } from '../_components/empty-state';
11import { Gauge } from '../_components/gauge';
12import { Section } from '../_components/section';
13
14/* ─── payload types (mirror /v1/admin/health) ────────────────────────────── */
15
16type HealthCheck = 'ok' | 'unreachable' | 'not_configured';
17
18interface HostMetrics {
19 cpuPercent: number | null;
20 memUsedBytes: number | null;
21 memTotalBytes: number | null;
22 diskPercent: number | null;
23 stealPercent: number | null;
24 instance?: string;
25}
26
27export interface HealthSummary {
28 checks: {
29 control_postgres: HealthCheck;
30 data_plane_postgres: HealthCheck;
31 runtime: HealthCheck;
32 redis: HealthCheck;
33 };
34 host: HostMetrics | null;
35}
36
37/* ─── live-polled health board ───────────────────────────────────────────── */
38
39const POLL_MS = 10_000;
40
41interface AuthReliabilitySnapshot {
42 redisConfigured: boolean;
43 redisOk: boolean | null;
44 counters: {
45 rateLimitDenied: number;
46 rateLimitMemoryFallback: number;
47 mailerFailures: number;
48 authRoute5xx: number;
49 };
50 watch: readonly string[];
51}
52
53export function HealthBoard({
54 apiOrigin,
55 initial,
56}: {
57 apiOrigin: string;
58 initial: HealthSummary | null;
59}) {
60 const [data, setData] = useState<HealthSummary | null>(initial);
61 const [authRel, setAuthRel] = useState<AuthReliabilitySnapshot | null>(null);
62 const [failed, setFailed] = useState(false);
63 const [updatedAt, setUpdatedAt] = useState<number | null>(initial ? Date.now() : null);
64
65 const load = useCallback(async () => {
66 try {
67 const res = await fetch(`${apiOrigin}/v1/admin/health`, {
68 credentials: 'include',
69 headers: { accept: 'application/json' },
70 });
71 if (!res.ok) throw new Error(`health failed: ${res.status}`);
72 const json = (await res.json()) as HealthSummary;
73 setData(json);
74 setUpdatedAt(Date.now());
75 setFailed(false);
76 } catch {
77 setFailed(true);
78 }
79 try {
80 const res = await fetch(`${apiOrigin}/v1/admin/auth-reliability`, {
81 credentials: 'include',
82 headers: { accept: 'application/json' },
83 });
84 if (res.ok) setAuthRel((await res.json()) as AuthReliabilitySnapshot);
85 } catch {
86 // optional panel — older API builds won't have the route yet
87 }
88 }, [apiOrigin]);
89
90 useEffect(() => {
91 void load();
92 const timer = setInterval(() => {
93 if (document.visibilityState === 'visible') void load();
94 }, POLL_MS);
95 return () => clearInterval(timer);
96 }, [load]);
97
98 if (data === null) {
99 if (failed) {
100 return (
101 <EmptyState
102 icon={<TriangleAlertIcon size={28} />}
103 title="health data unavailable"
104 message="the api didn't answer the health check — it may be restarting or your session may have expired."
105 action={<EmptyStateButton onClick={() => void load()}>retry now</EmptyStateButton>}
106 />
107 );
108 }
109 return (
110 <EmptyState
111 icon={<ActivityIcon size={28} />}
112 title="loading health…"
113 message="running the live dependency checks."
114 />
115 );
116 }
117
118 const { checks, host } = data;
119 const memPercent =
120 host && host.memUsedBytes !== null && host.memTotalBytes !== null && host.memTotalBytes > 0
121 ? (host.memUsedBytes / host.memTotalBytes) * 100
122 : null;
123
124 return (
125 <div className="flex flex-col gap-10">
126 <header className="flex flex-wrap items-end justify-between gap-4">
127 <div className="flex flex-col gap-2">
128 <div className="flex items-center gap-2">
129 <span className="text-[var(--color-primary)]">
130 <ActivityIcon size={20} />
131 </span>
132 <h1 className="font-mono text-xl tracking-tight">platform health</h1>
133 </div>
134 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
135 live signal on the engine — dependency checks and real host load, refreshed every
136 10 seconds. real numbers only; anything we can&apos;t prove shows &ldquo;—&rdquo;.
137 </p>
138 </div>
139 <LiveBadge updatedAt={updatedAt} stale={failed} />
140 </header>
141
142 {/* ── dependency checks ────────────────────────────────────────── */}
143 <Section title="dependency checks" icon={<DatabaseIcon size={16} />}>
144 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
145 {(
146 [
147 ['control_postgres', 'control db'],
148 ['data_plane_postgres', 'data plane'],
149 ['runtime', 'runtime'],
150 ['redis', 'redis'],
151 ] as const
152 ).map(([key, label]) => (
153 <CheckTile key={key} label={label} state={checks[key]} />
154 ))}
155 </div>
156 </Section>
157
158 {/* ── host load gauges ─────────────────────────────────────────── */}
159 <Section
160 title={host?.instance ? `host load · ${host.instance}` : 'host load'}
161 icon={<ActivityIcon size={16} />}
162 >
163 {host === null ? (
164 <EmptyState
165 icon={<TriangleAlertIcon size={24} />}
166 title="monitoring not connected"
167 message="host CPU/RAM/disk gauges appear here once Prometheus is wired up — no fabricated percentages in the meantime."
168 />
169 ) : (
170 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
171 <Gauge
172 label="cpu"
173 icon={<ActivityIcon size={14} />}
174 percent={host.cpuPercent}
175 redAt={85}
176 amberAt={70}
177 detail={host.cpuPercent === null ? undefined : 'busy · all cores'}
178 />
179 <Gauge
180 label="ram"
181 icon={<DatabaseIcon size={14} />}
182 percent={memPercent}
183 redAt={85}
184 amberAt={70}
185 detail={
186 host.memUsedBytes !== null && host.memTotalBytes !== null
187 ? `${formatGiB(host.memUsedBytes)} / ${formatGiB(host.memTotalBytes)} GB`
188 : undefined
189 }
190 />
191 <Gauge
192 label="disk · /"
193 icon={<DatabaseIcon size={14} />}
194 percent={host.diskPercent}
195 redAt={85}
196 amberAt={70}
197 detail={host.diskPercent === null ? undefined : 'root filesystem'}
198 />
199 <Gauge
200 label="cpu steal"
201 icon={<ZapIcon size={14} />}
202 percent={host.stealPercent}
203 redAt={25}
204 amberAt={10}
205 detail={host.stealPercent === null ? undefined : 'hypervisor contention'}
206 />
207 </div>
208 )}
209 </Section>
210
211 {/* ── S6 auth reliability ──────────────────────────────────────── */}
212 <Section title="auth reliability (S6)" icon={<ZapIcon size={16} />}>
213 {authRel === null ? (
214 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
215 auth reliability snapshot not available yet (deploy API with S6, or route missing).
216 </p>
217 ) : (
218 <div className="flex flex-col gap-4">
219 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
220 <MetricTile
221 label="rate-limit denials"
222 value={authRel.counters.rateLimitDenied}
223 hint="since process boot"
224 />
225 <MetricTile
226 label="memory fallback"
227 value={authRel.counters.rateLimitMemoryFallback}
228 hint={
229 authRel.redisOk === false
230 ? 'redis down — limits per process'
231 : '0 = redis path in use'
232 }
233 />
234 <MetricTile
235 label="mailer failures"
236 value={authRel.counters.mailerFailures}
237 hint="hard fails after fallback"
238 />
239 <MetricTile
240 label="auth route 5xx"
241 value={authRel.counters.authRoute5xx}
242 hint="internal errors on /auth*"
243 />
244 </div>
245 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
246 redis:{' '}
247 {authRel.redisConfigured
248 ? authRel.redisOk
249 ? 'ok'
250 : 'unreachable'
251 : 'not configured'}{' '}
252 · also scrape{' '}
253 <code className="text-[var(--color-text)]">GET /metrics</code> for{' '}
254 <code>briven_auth_*</code> counters
255 </p>
256 </div>
257 )}
258 </Section>
259 </div>
260 );
261}
262
263function MetricTile({
264 label,
265 value,
266 hint,
267}: {
268 label: string;
269 value: number;
270 hint: string;
271}) {
272 return (
273 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4">
274 <p className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
275 {label}
276 </p>
277 <p className="mt-2 font-mono text-2xl text-[var(--color-text)]">{value}</p>
278 <p className="mt-1 font-mono text-[10px] text-[var(--color-text-muted)]">{hint}</p>
279 </div>
280 );
281}
282
283/* ─── small pieces ───────────────────────────────────────────────────────── */
284
285function LiveBadge({ updatedAt, stale }: { updatedAt: number | null; stale: boolean }) {
286 return (
287 <span className="flex items-center gap-2 rounded-full border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] text-[var(--color-text-subtle)]">
288 <StatusDot state={stale ? 'unreachable' : 'ok'} />
289 {stale
290 ? 'stale — retrying'
291 : updatedAt
292 ? `live · updated ${new Date(updatedAt).toLocaleTimeString()}`
293 : 'live'}
294 </span>
295 );
296}
297
298/** Pulsing status dot — green pulse ok, red pulse unreachable, amber static. */
299function StatusDot({ state }: { state: HealthCheck }) {
300 const color =
301 state === 'ok'
302 ? 'bg-[var(--color-success)]'
303 : state === 'unreachable'
304 ? 'bg-[var(--color-error)]'
305 : 'bg-[var(--color-warning)]';
306 return (
307 <span className="relative flex h-2.5 w-2.5 shrink-0" aria-hidden>
308 {state !== 'not_configured' ? (
309 <span
310 className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${color}`}
311 />
312 ) : null}
313 <span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${color}`} />
314 </span>
315 );
316}
317
318function CheckTile({ label, state }: { label: string; state: HealthCheck }) {
319 return (
320 <div className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
321 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
322 {label}
323 </p>
324 <div className="flex items-center gap-3">
325 <StatusDot state={state} />
326 <span className="font-mono text-sm text-[var(--color-text)]">
327 {state === 'not_configured' ? 'not configured' : state}
328 </span>
329 </div>
330 </div>
331 );
332}
333
334/** Bytes → GB string (decimal, 1dp) for human-readable memory display. */
335function formatGiB(bytes: number): string {
336 return (bytes / 1024 / 1024 / 1024).toFixed(1);
337}