1import { useState } from "react";2import { Link, useSearchParams } from "react-router";3import { api, ApiError, type AuditEvent, type Health, type Job, type Metrics } from "../api";4import { useFetch, ago, fmtBytes, fmtDate, num } from "../hooks";5import { Badge, Bool, Empty, ErrorBox, JobStatus, JsonTree, KV, Loading, Tabs, Td } from "../components/ui";6import { Breakdown, Sparkline } from "../components/charts";7import { useMe } from "../App";89export default function Operations() {10 const { me } = useMe();11 const [sp] = useSearchParams();12 const [tab, setTab] = useState(sp.get("job") ? "Jobs" : "Health");13 const health = useFetch<Health>("ops/health");14 const metrics = useFetch<Metrics>(tab === "Metrics" ? "ops/metrics" : null);15 const backups = useFetch<{ dir: string; remote: string; files: { name: string; bytes: number; mtime: string }[]; runs: { ts: string; details: Record<string, unknown> }[] }>(tab === "Backups" ? "ops/backups" : null);16 const [jobState, setJobState] = useState("");17 const jobs = useFetch<{ jobs: Job[]; stats: Health["jobs"] }>(tab === "Jobs" ? `jobs?limit=100${jobState ? `&state=${jobState}` : ""}` : null);18 const focusJob = useFetch<Job>(sp.get("job") ? `jobs/${sp.get("job")}` : null);19 const [auditOffset, setAuditOffset] = useState(0);20 const audit = useFetch<{ events: AuditEvent[]; total: number }>(tab === "Audit" ? `audit?limit=50&offset=${auditOffset}` : null);21 const docs = useFetch<{ documents: string[] }>(tab === "Docs" ? "/docs/index" : null);22 const [doc, setDoc] = useState<{ name: string; text: string } | null>(null);23 const [verify, setVerify] = useState<{ ok: boolean; checked: number; head?: string; broken_at_seq?: number } | null>(null);24 const [backupJob, setBackupJob] = useState<string | null>(null);25 const [err, setErr] = useState<ApiError | null>(null);26 const h = health.data;27 return (28 <div>29 <div className="row between"><h1>Operations</h1>{h && <span className={`badge ${h.status === "ok" ? "good" : "serious"}`}>{h.status} · {h.host} · up {Math.round(h.uptime_s / 60)} min</span>}</div>30 <ErrorBox error={err} />31 <Tabs tabs={["Health", "Metrics", "Jobs", "Backups", "Audit", "Docs"]} active={tab} onChange={setTab} />32 {tab === "Health" && (health.loading ? <Loading /> : health.error ? <ErrorBox error={health.error} /> : h && (33 <div className="grid c2">34 <div className="card"><h3>Service</h3><KV items={[["Version", h.version], ["Public URL", h.public_url], ["Database", <span><Bool v={h.db.ok} /> {h.db.latency_ms} ms · {fmtBytes(h.db.size_bytes)}</span>], ["Migrations", h.db.migrations.map((m) => m.name).join(", ")], ["Egress proxy", <span><Bool v={h.proxy.listening} yes="listening" no="DOWN" /> 127.0.0.1:{h.proxy.port}</span>], ["sandbox-exec", <Bool v={h.sandbox} yes="enabled" no="disabled" />], ["Data dir", h.data_dir], ["Disk", h.disk ? `${fmtBytes(h.disk.free_bytes)} free of ${fmtBytes(h.disk.total_bytes)}` : "—"]]} /></div>35 <div className="card"><h3>Workers ({h.live_workers} live)</h3>{h.workers.length ? h.workers.map((w) => <div key={w.name} className="row between" style={{ fontSize: 13, padding: "4px 0", borderBottom: "1px solid var(--border)" }}><span>{w.name} <span className="hint">pid {w.pid} · v{w.version}</span></span><span className="hint">{w.current_job ? `job ${w.current_job.slice(0, 8)}` : "idle"} · {ago(w.last_seen)}</span></div>) : <div className="alert error">No worker heartbeat.</div>}36 <h3>Queue</h3><div className="row"><span className="badge">queued {h.jobs.queued}</span><span className="badge warn">running {h.jobs.running}</span><span className={`badge ${h.jobs.dead ? "critical" : ""}`}>dead {h.jobs.dead}</span></div><Breakdown items={Object.entries(h.jobs.by_state).map(([k, v]) => ({ label: k, n: v }))} /></div>37 </div>))}38 {tab === "Metrics" && (metrics.loading ? <Loading /> : metrics.error ? <ErrorBox error={metrics.error} /> : metrics.data && (39 <div className="grid c2">40 <div className="card"><h3>Execution latency (24 h, n={num(metrics.data.executions_24h.n)})</h3><div className="tiles"><div className="tile"><div className="k">p50</div><div className="v">{metrics.data.executions_24h.p50 === null ? "—" : `${Math.round(metrics.data.executions_24h.p50)} ms`}</div></div><div className="tile"><div className="k">p95</div><div className="v">{metrics.data.executions_24h.p95 === null ? "—" : `${Math.round(metrics.data.executions_24h.p95)} ms`}</div></div><div className="tile"><div className="k">max</div><div className="v">{metrics.data.executions_24h.max ?? "—"}</div></div><div className="tile"><div className="k">jobs done / dead</div><div className="v">{metrics.data.jobs_24h.done}/{metrics.data.jobs_24h.dead}</div></div></div>41 <h3>Executions per hour (48 h)</h3><Sparkline label="executions per hour" points={metrics.data.hourly.map((p) => ({ x: p.hour, y: p.n }))} />42 <h3>Egress decisions (24 h)</h3>{metrics.data.egress_24h.length ? <Breakdown items={metrics.data.egress_24h.map((e) => ({ label: e.decision, n: e.n, tone: e.decision === "block" ? "s8" : "s1" }))} /> : <span className="hint">no proxy traffic</span>}</div>43 <div className="card"><h3>By API (7 d)</h3>{metrics.data.by_api_7d.length ? <table className="tbl"><thead><tr><th>API</th><th>Calls</th><th>OK</th><th>Avg ms</th></tr></thead><tbody>{metrics.data.by_api_7d.map((a) => <tr key={a.name}><Td l="api">{a.name}</Td><Td l="calls" className="num">{a.n}</Td><Td l="ok" className="num">{a.ok}</Td><Td l="avg ms" className="num">{a.avg_ms === null ? "—" : Math.round(a.avg_ms)}</Td></tr>)}</tbody></table> : <Empty>No executions in 7 days.</Empty>}</div>44 </div>))}45 {tab === "Jobs" && (<div>46 {focusJob.data && <div className="card"><h3>Job {focusJob.data.id}</h3><JsonTree data={focusJob.data} open /></div>}47 <div className="filters"><select aria-label="Job state" value={jobState} onChange={(e) => setJobState(e.target.value)}><option value="">all states</option>{["queued", "running", "done", "failed", "dead", "cancelled"].map((s) => <option key={s}>{s}</option>)}</select></div>48 {jobs.loading && <Loading />}<ErrorBox error={jobs.error} />49 {jobs.data && jobs.data.jobs.length === 0 && <Empty>No jobs.</Empty>}50 {jobs.data && jobs.data.jobs.length > 0 && <table className="tbl"><thead><tr><th>Job</th><th>Kind</th><th>State</th><th>Attempts</th><th>Created</th><th>Finished</th><th>Error / result</th><th></th></tr></thead><tbody>51 {jobs.data.jobs.map((j) => <tr key={j.id}><Td l="job"><Link to={`/operations?job=${j.id}`} onClick={() => setTab("Jobs")}><code>{j.id.slice(0, 8)}</code></Link></Td><Td l="kind">{j.kind}</Td><Td l="state"><Badge v={j.state} /></Td><Td l="attempts" className="num">{j.attempts}/{j.max_attempts ?? "?"}</Td><Td l="created">{fmtDate(j.created_at)}</Td><Td l="finished">{fmtDate(j.finished_at)}</Td><Td l="detail" wide>{j.error ? <span className="hint" style={{ color: "var(--critical)" }}>{j.error.split("\n")[0].slice(0, 160)}</span> : j.result ? <span className="hint">{JSON.stringify(j.result).slice(0, 160)}</span> : "—"}</Td><Td l="">{(j.state === "dead" || j.state === "failed") && (me?.role === "admin" || me?.role === "operator") && <button className="btn sm" onClick={async () => { try { await api(`jobs/${j.id}/retry`, { method: "POST" }); jobs.reload(); } catch (ex) { setErr(ex as ApiError); } }}>retry</button>}</Td></tr>)}52 </tbody></table>}</div>)}53 {tab === "Backups" && (<div className="card"><div className="row between"><h3>Backups</h3>{me?.role === "admin" && <button className="btn sm" onClick={async () => { try { const r = await api<{ job_id: string }>("ops/backup", { method: "POST" }); setBackupJob(r.job_id); } catch (ex) { setErr(ex as ApiError); } }}>Run backup now</button>}</div>54 <JobStatus jobId={backupJob} label="backup" onDone={() => backups.reload()} />55 {backups.loading && <Loading />}<ErrorBox error={backups.error} />56 {backups.data && <><KV items={[["Directory", backups.data.dir], ["Remote copy", backups.data.remote || "—"]]} />57 {backups.data.files.length ? <table className="tbl"><thead><tr><th>File</th><th>Size</th><th>Modified</th></tr></thead><tbody>{backups.data.files.map((f) => <tr key={f.name}><Td l="file"><code>{f.name}</code></Td><Td l="size" className="num">{fmtBytes(f.bytes)}</Td><Td l="modified">{fmtDate(f.mtime)}</Td></tr>)}</tbody></table> : <Empty>No backup files yet.</Empty>}58 <h3>Backup runs</h3>{backups.data.runs.length ? backups.data.runs.map((r, i) => <details key={i} className="card tight"><summary>{fmtDate(r.ts)} · return code {String(r.details.returncode)}</summary><pre>{String(r.details.stdout ?? "")}{String(r.details.stderr ?? "")}</pre></details>) : <span className="hint">none recorded</span>}</>}</div>)}59 {tab === "Audit" && (<div className="card"><div className="row between"><h3>Audit log (append-only, hash-chained)</h3><button className="btn sm" onClick={async () => { try { setVerify(await api("audit/verify")); } catch (ex) { setErr(ex as ApiError); } }}>Verify chain</button></div>60 {verify && <div className={`alert ${verify.ok ? "ok" : "error"}`}>{verify.ok ? `Chain intact: ${verify.checked} events verified, head ${verify.head?.slice(0, 16)}…` : `Chain BROKEN at seq ${verify.broken_at_seq} after ${verify.checked} valid events`}</div>}61 {audit.loading && <Loading />}<ErrorBox error={audit.error} />62 {audit.data && <><table className="tbl"><thead><tr><th>#</th><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Details</th></tr></thead><tbody>{audit.data.events.map((a) => <tr key={a.seq}><Td l="#">{a.seq}</Td><Td l="when">{fmtDate(a.ts)}</Td><Td l="actor">{a.actor_type}:{a.actor_id?.slice(0, 8)}</Td><Td l="action"><b>{a.action}</b></Td><Td l="target">{a.target_type ? `${a.target_type}:${a.target_id?.slice(0, 8)}` : "—"}</Td><Td l="details" wide><details><summary className="hint">{JSON.stringify(a.details).slice(0, 80)}</summary><JsonTree data={a.details} open /><div className="hint">hash <code>{a.hash.slice(0, 20)}…</code> prev <code>{a.prev_hash?.slice(0, 20) ?? "∅"}</code></div></details></Td></tr>)}</tbody></table>63 <div className="pager"><span>{audit.data.total} events</span><button className="btn sm" disabled={auditOffset === 0} onClick={() => setAuditOffset(Math.max(0, auditOffset - 50))}>‹ newer</button><button className="btn sm" disabled={auditOffset + 50 >= audit.data.total} onClick={() => setAuditOffset(auditOffset + 50)}>older ›</button></div></>}</div>)}64 {tab === "Docs" && (<div className="grid c2"><div className="card"><h3>Documentation</h3><p><a href="/docs" target="_blank" rel="noreferrer">Interactive API reference (/docs)</a></p>{docs.loading && <Loading />}<ErrorBox error={docs.error} />{docs.data?.documents.map((d) => <div key={d}><button className="btn sm" style={{ margin: "2px 0" }} onClick={async () => { try { setDoc({ name: d, text: await api<string>(`/docs/md/${d}`, { raw: true }) }); } catch (ex) { setErr(ex as ApiError); } }}>{d}.md</button></div>)}</div><div className="card"><h3>{doc?.name ?? "Viewer"}</h3>{doc ? <pre>{doc.text}</pre> : <div className="empty">Select a document.</div>}</div></div>)}65 </div>66 );67}68