| import React, { useEffect, useMemo, useState } from "react"; |
| import Modal from "./Modal"; |
| import { Badge, ButtonSoft, Card } from "./ui"; |
| import { apiGetJson } from "../api/client"; |
| import type { ApiHealthResponse, ApiObservabilityMetricsResponse } from "../api/contracts"; |
|
|
| export default function HealthStatusModal({ |
| open, |
| onClose, |
| }: { |
| open: boolean; |
| onClose: () => void; |
| }) { |
| const [loading, setLoading] = useState(false); |
| const [data, setData] = useState<ApiHealthResponse | null>(null); |
| const [metrics, setMetrics] = useState<ApiObservabilityMetricsResponse | null>(null); |
| const [error, setError] = useState<string>(""); |
|
|
| const envSorted = useMemo(() => { |
| if (!data?.env) return [] as Array<[string, boolean]>; |
| return Object.entries(data.env).sort((a, b) => a[0].localeCompare(b[0])); |
| }, [data]); |
|
|
| const authAllowedDomains = useMemo(() => { |
| const domains = Array.isArray(data?.auth?.allowed_domains) ? data?.auth?.allowed_domains : []; |
| return [...domains].sort((a, b) => String(a).localeCompare(String(b))); |
| }, [data]); |
|
|
| const fetchHealth = async () => { |
| setLoading(true); |
| setError(""); |
| try { |
| const [healthJson, metricsJson] = await Promise.all([ |
| apiGetJson<ApiHealthResponse>("/api/health", { cache: "no-store" }), |
| apiGetJson<ApiObservabilityMetricsResponse>("/api/observability/metrics?limit=25", { cache: "no-store" }).catch( |
| () => null |
| ), |
| ]); |
| setData(healthJson); |
| setMetrics(metricsJson); |
| } catch (e: any) { |
| setError(e?.message || String(e)); |
| setData(null); |
| setMetrics(null); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| useEffect(() => { |
| if (!open) return; |
| |
| fetchHealth(); |
| |
| }, [open]); |
|
|
| const overallBadge = data?.ok ? ( |
| <Badge tone="green">Healthy</Badge> |
| ) : ( |
| <Badge tone="red">Issues found</Badge> |
| ); |
|
|
| return ( |
| <Modal open={open} onClose={onClose} title="System status"> |
| <div className="space-y-4"> |
| <div className="flex flex-wrap items-center justify-between gap-2"> |
| <div className="flex items-center gap-2"> |
| {data ? overallBadge : <Badge tone="slate">Unknown</Badge>} |
| {data?.summary ? ( |
| <span className="text-sm text-slate-600"> |
| Required checks: {Number(data.summary.required_ok || 0)}/{Number(data.summary.required_total || 0)} |
| </span> |
| ) : null} |
| </div> |
| |
| <div className="flex items-center gap-2"> |
| <ButtonSoft onClick={fetchHealth} disabled={loading}> |
| {loading ? "Refreshing…" : "Refresh"} |
| </ButtonSoft> |
| <ButtonSoft onClick={onClose}> |
| Close |
| </ButtonSoft> |
| </div> |
| </div> |
| |
| {error ? ( |
| <div className="mt-panel-danger rounded-lg border p-3 text-sm"> |
| Failed to load status: <span className="font-mono">{error}</span> |
| </div> |
| ) : null} |
| |
| <Card> |
| <div className="p-4"> |
| <div className="mb-2 text-sm font-semibold text-slate-800">Checks</div> |
| {!data ? ( |
| <div className="text-sm text-slate-600">No data loaded yet.</div> |
| ) : ( |
| <div className="space-y-2"> |
| {data.checks.map((c, idx) => ( |
| <div |
| key={`${c.name}-${idx}`} |
| className="flex flex-col gap-1 rounded-md border border-slate-200 bg-white p-3" |
| > |
| <div className="flex items-center justify-between gap-2"> |
| <div className="font-medium text-slate-900">{c.name}</div> |
| {c.ok ? <Badge tone="green">OK</Badge> : <Badge tone="red">Fail</Badge>} |
| </div> |
| <div className="text-sm text-slate-600"> |
| {[c.kind, c.path].filter(Boolean).join(" • ")} |
| </div> |
| {c.error ? <div className="mt-text-danger text-sm">{c.error}</div> : null} |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| </Card> |
| |
| <Card> |
| <div className="p-4"> |
| <div className="mb-2 text-sm font-semibold text-slate-800">Environment (presence only)</div> |
| {!data ? ( |
| <div className="text-sm text-slate-600">No data loaded yet.</div> |
| ) : ( |
| <div className="space-y-2"> |
| {envSorted.map(([k, present]) => ( |
| <div |
| key={k} |
| className="flex items-center justify-between gap-2 rounded-md border border-slate-200 bg-white p-3" |
| > |
| <div className="font-mono text-sm text-slate-900">{k}</div> |
| {present ? <Badge tone="green">Present</Badge> : <Badge tone="amber">Missing</Badge>} |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| </Card> |
| |
| <Card> |
| <div className="p-4"> |
| <div className="mb-2 text-sm font-semibold text-slate-800">Authentication</div> |
| {!data?.auth ? ( |
| <div className="text-sm text-slate-600">Auth diagnostics unavailable in this response.</div> |
| ) : ( |
| <div className="space-y-2"> |
| <div className="flex flex-wrap items-center gap-2"> |
| {data.auth.required ? <Badge tone="amber">Auth required</Badge> : <Badge tone="slate">Auth optional</Badge>} |
| {data.auth.enabled ? <Badge tone="green">Auth enabled</Badge> : <Badge tone="red">Auth not enabled</Badge>} |
| </div> |
| <div className="rounded-md border border-slate-200 bg-white p-3 text-xs text-slate-700"> |
| <div> |
| <span className="font-semibold text-slate-900">Issuer:</span> {data.auth.issuer || "Not configured"} |
| </div> |
| <div className="mt-1"> |
| <span className="font-semibold text-slate-900">Audience:</span> {data.auth.audience || "Not configured"} |
| </div> |
| <div className="mt-1"> |
| <span className="font-semibold text-slate-900">Email claim:</span> {data.auth.email_claim || "Not configured"} |
| </div> |
| <div className="mt-1"> |
| <span className="font-semibold text-slate-900">Allowed domains:</span>{" "} |
| {authAllowedDomains.length ? authAllowedDomains.join(", ") : "Not configured"} |
| </div> |
| </div> |
| {data.auth.config_error ? ( |
| <div className="mt-panel-danger rounded-md border p-3 text-xs"> |
| <div className="font-semibold">Config error</div> |
| <div className="mt-1">{data.auth.config_error}</div> |
| </div> |
| ) : null} |
| {Array.isArray(data.auth.config_details) && data.auth.config_details.length ? ( |
| <div className="mt-panel-warning rounded-md border p-3 text-xs"> |
| <div className="font-semibold">Config details</div> |
| <div className="mt-2 space-y-1"> |
| {data.auth.config_details.map((line, idx) => ( |
| <div key={`${line}-${idx}`}>• {line}</div> |
| ))} |
| </div> |
| </div> |
| ) : null} |
| {Array.isArray(data.auth.config_warnings) && data.auth.config_warnings.length ? ( |
| <div className="mt-panel-primary rounded-md border p-3 text-xs"> |
| <div className="font-semibold">Config warnings</div> |
| <div className="mt-2 space-y-1"> |
| {data.auth.config_warnings.map((line, idx) => ( |
| <div key={`${line}-${idx}`}>• {line}</div> |
| ))} |
| </div> |
| </div> |
| ) : null} |
| </div> |
| )} |
| </div> |
| </Card> |
| |
| {metrics ? ( |
| <Card> |
| <div className="p-4"> |
| <div className="mb-2 text-sm font-semibold text-slate-800">API observability</div> |
| <div className="mb-3 text-xs text-slate-600"> |
| Requests: {metrics.totals.requests} • Errors: {metrics.totals.errors} • Error rate:{" "} |
| {(metrics.totals.error_rate * 100).toFixed(2)}% |
| </div> |
| <div className="space-y-2"> |
| {metrics.endpoints.slice(0, 8).map((m) => ( |
| <div key={m.endpoint} className="rounded-md border border-slate-200 bg-white p-3"> |
| <div className="flex items-center justify-between gap-2"> |
| <div className="font-mono text-xs text-slate-900">{m.endpoint}</div> |
| {m.error_requests > 0 ? ( |
| <Badge tone="red">{m.error_requests} errors</Badge> |
| ) : ( |
| <Badge tone="green">No errors</Badge> |
| )} |
| </div> |
| <div className="mt-1 text-xs text-slate-600"> |
| req={m.total_requests} • avg={m.avg_latency_ms}ms • p95={m.p95_latency_ms}ms • max={m.max_latency_ms}ms |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| </Card> |
| ) : null} |
| </div> |
| </Modal> |
| ); |
| } |
|
|