spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1"use client";23import { useCallback, useEffect, useState } from "react";4import { Page, PageHeader, Stat } from "@/components/ui/section";5import { RightsBadge, StatusBadge } from "@/components/ui/status-badge";6import { RelativeTime } from "@/components/ui/freshness";7import { clientApi } from "@/lib/client-api";8import { cx, formatCompact, formatDateTime, formatDuration } from "@/lib/format";910const TOKEN_KEY = "ma-admin-token";11type Tab = "overview" | "connectors" | "schema" | "divergence" | "storage" | "discovery";1213interface Overview {14 version: string;15 role: string;16 uptime_s: number;17 memory: { rss: number; heapUsed: number };18 observations_total: number;19 events_total: number;20 queue_depth: number;21 rates: { observations_per_sec: number; events_per_min: number; http_per_min: number };22 tables: Array<{ relname: string; bytes: number }>;23 rate_limiter: Array<{ host: string; ratePerSec: number; pending: number }>;24}25interface ConnectorRow {26 metadata: Record<string, any>;27 health: Record<string, any>;28 paused: boolean;29 running: boolean;30 symbols: string[];31 schedule: Record<string, unknown> | null;32 consecutive_failures?: number;33 drift_strikes?: number;34 sockets: Array<{ url: string; open: boolean; reconnects: number }>;35}3637/** Token-gated admin console (token kept in localStorage, sent as x-ma-admin-token; never rendered). */38export function AdminConsole() {39 const [token, setToken] = useState<string>("");40 const [input, setInput] = useState("");41 const [tab, setTab] = useState<Tab>("overview");42 const [error, setError] = useState<string | null>(null);43 useEffect(() => {44 try {45 setToken(localStorage.getItem(TOKEN_KEY) ?? "");46 } catch {47 /* ignore */48 }49 }, []);50 const call = useCallback(51 async <T,>(path: string, init?: RequestInit) => {52 try {53 setError(null);54 return await clientApi<T>(path, { ...init, adminToken: token });55 } catch (e) {56 const msg = e instanceof Error ? e.message : "request failed";57 setError(msg);58 if (/token|unauthor/i.test(msg)) {59 localStorage.removeItem(TOKEN_KEY);60 setToken("");61 }62 throw e;63 }64 },65 [token],66 );67 if (!token) {68 return (69 <Page>70 <PageHeader kicker="Admin" title="Market Atlas console" lead="Enter the admin token configured on the API (MA_ADMIN_TOKEN). It is stored in this browser only." />71 <form72 className="flex max-w-md gap-2"73 onSubmit={(e) => {74 e.preventDefault();75 if (!input.trim()) return;76 localStorage.setItem(TOKEN_KEY, input.trim());77 setToken(input.trim());78 }}79 >80 <input type="password" value={input} onChange={(e) => setInput(e.target.value)} placeholder="admin token" className="mono h-11 flex-1 rounded-md border border-rule bg-surface px-3 text-sm outline-none focus:border-rule-strong" autoComplete="off" />81 <button type="submit" className="h-11 rounded-md bg-ink px-4 text-sm font-medium text-canvas">82 Unlock83 </button>84 </form>85 {error && <p className="mt-3 text-sm text-negative">{error}</p>}86 </Page>87 );88 }89 return (90 <Page wide>91 <PageHeader92 kicker="Admin"93 title="Console"94 actions={95 <button96 type="button"97 onClick={() => {98 localStorage.removeItem(TOKEN_KEY);99 setToken("");100 }}101 className="h-9 rounded-md border border-rule px-3 text-xs text-ink-2 hover:text-ink"102 >103 Lock104 </button>105 }106 />107 <nav className="mb-4 flex flex-wrap gap-1 border-b border-rule">108 {(["overview", "connectors", "schema", "divergence", "storage", "discovery"] as Tab[]).map((t) => (109 <button key={t} type="button" onClick={() => setTab(t)} className={cx("h-10 px-3 text-sm capitalize", tab === t ? "border-b-2 border-ink font-medium text-ink" : "text-ink-2 hover:text-ink")}>110 {t === "schema" ? "Schema changes" : t}111 </button>112 ))}113 </nav>114 {error && <p className="mb-3 rounded-md border border-negative/40 bg-negative-soft px-3 py-2 text-sm text-negative">{error}</p>}115 {tab === "overview" && <OverviewTab call={call} />}116 {tab === "connectors" && <ConnectorsTab call={call} />}117 {tab === "schema" && <SchemaTab call={call} />}118 {tab === "divergence" && <DivergenceTab call={call} />}119 {tab === "storage" && <StorageTab call={call} />}120 {tab === "discovery" && <DiscoveryTab call={call} />}121 </Page>122 );123}124125type Call = <T>(path: string, init?: RequestInit) => Promise<T>;126127function OverviewTab({ call }: { call: Call }) {128 const [o, setO] = useState<Overview | null>(null);129 useEffect(() => {130 const load = () => call<Overview>("/v1/admin/overview").then(setO).catch(() => {});131 load();132 const t = setInterval(load, 5000);133 return () => clearInterval(t);134 }, [call]);135 if (!o) return <p className="text-sm text-ink-3">Loading…</p>;136 return (137 <div className="space-y-6">138 <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-3 lg:grid-cols-6">139 <Stat label="Version / role" value={`v${o.version}`} sub={o.role} />140 <Stat label="Uptime" value={formatDuration(o.uptime_s * 1000)} />141 <Stat label="RSS" value={formatCompact(o.memory.rss)} sub={`heap ${formatCompact(o.memory.heapUsed)}`} />142 <Stat label="Observations" value={formatCompact(o.observations_total, 2)} sub={`${o.rates.observations_per_sec.toFixed(1)} / s`} />143 <Stat label="Events" value={formatCompact(o.events_total)} sub={`${o.rates.events_per_min.toFixed(1)} / min`} />144 <Stat label="Queue depth" value={o.queue_depth} sub={`HTTP ${o.rates.http_per_min.toFixed(0)} / min`} tone={o.queue_depth > 50_000 ? "warn" : undefined} />145 </div>146 <div className="grid grid-cols-1 [&>*]:min-w-0 gap-4 lg:grid-cols-2">147 <div className="overflow-x-auto rounded-md border border-rule bg-surface">148 <table className="table-dense">149 <thead>150 <tr>151 <th>Table</th>152 <th className="text-right">Size</th>153 </tr>154 </thead>155 <tbody>156 {o.tables.map((t) => (157 <tr key={t.relname}>158 <td className="mono">{t.relname}</td>159 <td className="num">{formatCompact(t.bytes)}B</td>160 </tr>161 ))}162 </tbody>163 </table>164 </div>165 <div className="overflow-x-auto rounded-md border border-rule bg-surface">166 <table className="table-dense">167 <thead>168 <tr>169 <th>Rate-limited host</th>170 <th className="text-right">req / s</th>171 <th className="text-right">Pending</th>172 </tr>173 </thead>174 <tbody>175 {o.rate_limiter.map((r) => (176 <tr key={r.host}>177 <td className="mono">{r.host}</td>178 <td className="num">{r.ratePerSec}</td>179 <td className="num">{r.pending}</td>180 </tr>181 ))}182 </tbody>183 </table>184 </div>185 </div>186 </div>187 );188}189190function ConnectorsTab({ call }: { call: Call }) {191 const [rows, setRows] = useState<ConnectorRow[]>([]);192 const [sel, setSel] = useState<string | null>(null);193 const [detail, setDetail] = useState<Record<string, any> | null>(null);194 const [busy, setBusy] = useState<string | null>(null);195 const [testResult, setTestResult] = useState<string | null>(null);196 const load = useCallback(() => call<ConnectorRow[]>("/v1/admin/connectors").then(setRows).catch(() => {}), [call]);197 useEffect(() => {198 load();199 const t = setInterval(load, 5000);200 return () => clearInterval(t);201 }, [load]);202 useEffect(() => {203 if (!sel) return;204 call<Record<string, any>>(`/v1/admin/connectors/${sel}`).then(setDetail).catch(() => {});205 }, [sel, call]);206 const action = async (id: string, a: string) => {207 setBusy(`${id}:${a}`);208 setTestResult(null);209 try {210 const r = await call<Record<string, unknown>>(`/v1/admin/connectors/${id}/${a}`, { method: "POST", body: "{}" });211 if (a === "test") setTestResult(JSON.stringify(r));212 await load();213 } finally {214 setBusy(null);215 }216 };217 return (218 <div className="grid grid-cols-1 [&>*]:min-w-0 gap-4 xl:grid-cols-[1fr_420px]">219 <div className="overflow-x-auto rounded-md border border-rule bg-surface">220 <table className="table-dense">221 <thead>222 <tr>223 <th>Connector</th>224 <th>State</th>225 <th className="text-right">Msg/min</th>226 <th className="text-right">Errors</th>227 <th className="text-right">Score</th>228 <th>Last message</th>229 <th>Actions</th>230 </tr>231 </thead>232 <tbody>233 {rows.map((r) => {234 const id = r.metadata.id as string;235 return (236 <tr key={id} className={cx(sel === id && "bg-surface-2")}>237 <td>238 <button type="button" onClick={() => setSel(id)} className="text-left hover:underline">239 <span className="mono font-medium">{id}</span>240 <span className="block text-[11px] text-ink-3">241 {r.metadata.sourceType} · <RightsBadge status={r.metadata.rightsStatus} />242 </span>243 </button>244 </td>245 <td>246 <StatusBadge status={r.health.state} />247 {r.paused && <span className="ml-1 text-[10px] text-ink-3">paused</span>}248 </td>249 <td className="num">{r.health.messages1m}</td>250 <td className="num">{r.health.errorsTotal}</td>251 <td className="num">{r.health.reliabilityScore ?? "—"}</td>252 <td className="text-xs text-ink-3">{r.health.lastMessageAt ? <RelativeTime value={r.health.lastMessageAt} /> : "—"}</td>253 <td>254 <div className="flex gap-1">255 {(r.paused ? ["resume"] : ["pause", "restart"]).concat(["test"]).map((a) => (256 <button key={a} type="button" disabled={busy != null} onClick={() => action(id, a)} className="h-7 rounded border border-rule px-2 text-[11px] text-ink-2 hover:text-ink disabled:opacity-50">257 {busy === `${id}:${a}` ? "…" : a}258 </button>259 ))}260 </div>261 </td>262 </tr>263 );264 })}265 </tbody>266 </table>267 {testResult && <pre className="mono m-3 overflow-x-auto rounded bg-surface-2 p-2 text-xs">{testResult}</pre>}268 </div>269 <div className="rounded-md border border-rule bg-surface p-4 text-sm">270 {!sel && <p className="text-ink-3">Select a connector to inspect health history, schema fingerprints, recent observations and persisted state.</p>}271 {sel && detail && (272 <div className="space-y-4">273 <div>274 <div className="mono text-xs text-ink-3">{sel}</div>275 <div className="font-medium">{detail.metadata?.name}</div>276 <div className="mt-1 text-xs text-ink-2">{detail.metadata?.description}</div>277 </div>278 <div className="grid grid-cols-2 gap-x-4 text-xs">279 {[280 ["State", detail.health?.state],281 ["Messages", detail.health?.messagesTotal],282 ["Errors", detail.health?.errorsTotal],283 ["Reconnects", detail.health?.reconnects],284 ["p50 latency", detail.health?.medianLatencyMs != null ? formatDuration(detail.health.medianLatencyMs) : "—"],285 ["Instruments", detail.health?.instrumentsCovered],286 ["Parse success", detail.health?.parseSuccessRate != null ? `${Math.round(detail.health.parseSuccessRate * 100)}%` : "—"],287 ["Last error", detail.health?.lastError ?? "—"],288 ].map(([k, v]) => (289 <div key={String(k)} className="flex justify-between gap-2 border-b border-rule py-1">290 <span className="text-ink-3">{String(k)}</span>291 <span className="mono truncate">{String(v ?? "—")}</span>292 </div>293 ))}294 </div>295 <div>296 <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Schema fingerprints</div>297 <pre className="mono overflow-x-auto rounded bg-surface-2 p-2 text-[11px]">{JSON.stringify(detail.fingerprints ?? {}, null, 1)}</pre>298 </div>299 <div>300 <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Schema changes</div>301 {(detail.schema_changes ?? []).length === 0 ? (302 <p className="text-xs text-ink-3">None recorded.</p>303 ) : (304 <ul className="space-y-1 text-xs">305 {(detail.schema_changes as Array<Record<string, any>>).map((c) => (306 <li key={c.id} className="flex items-center justify-between gap-2">307 <span className="mono">308 {c.kind}: {c.old_fingerprint ?? "∅"} → {c.new_fingerprint}309 </span>310 {c.acknowledged ? (311 <span className="text-ink-3">ack</span>312 ) : (313 <button type="button" onClick={() => call(`/v1/admin/connectors/${sel}/schema-changes/${c.id}/ack`, { method: "POST", body: "{}" }).then(() => call<Record<string, any>>(`/v1/admin/connectors/${sel}`).then(setDetail))} className="rounded border border-rule px-1.5 text-[10px]">314 acknowledge315 </button>316 )}317 </li>318 ))}319 </ul>320 )}321 </div>322 <div>323 <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Recent observations</div>324 <div className="max-h-48 overflow-auto">325 <table className="table-dense">326 <tbody>327 {(detail.recent_observations as Array<Record<string, any>> | undefined)?.map((o, i) => (328 <tr key={i}>329 <td className="mono text-[11px] text-ink-3">{formatDateTime(o.received_at, { seconds: true })}</td>330 <td className="mono text-[11px]">{o.instrument_id}</td>331 <td className="mono text-[11px]">{o.field}</td>332 <td className="num text-[11px]">{o.value}</td>333 </tr>334 ))}335 </tbody>336 </table>337 </div>338 </div>339 <div>340 <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-ink-3">Persisted state (redacted)</div>341 <pre className="mono max-h-40 overflow-auto rounded bg-surface-2 p-2 text-[11px]">{JSON.stringify(detail.state ?? {}, null, 1)}</pre>342 </div>343 </div>344 )}345 </div>346 </div>347 );348}349350function SchemaTab({ call }: { call: Call }) {351 const [rows, setRows] = useState<Array<Record<string, any>>>([]);352 useEffect(() => {353 call<Array<Record<string, any>>>("/v1/admin/schema-changes").then(setRows).catch(() => {});354 }, [call]);355 return (356 <div className="overflow-x-auto rounded-md border border-rule bg-surface">357 <table className="table-dense">358 <thead>359 <tr>360 <th>Detected</th>361 <th>Connector</th>362 <th>Kind</th>363 <th>Old</th>364 <th>New</th>365 <th />366 </tr>367 </thead>368 <tbody>369 {rows.length === 0 && (370 <tr>371 <td colSpan={6} className="py-6 text-center text-ink-3">372 No unacknowledged schema changes.373 </td>374 </tr>375 )}376 {rows.map((r) => (377 <tr key={r.id}>378 <td className="mono text-xs">{formatDateTime(r.detected_at, { seconds: true })}</td>379 <td className="mono">{r.connector_id}</td>380 <td>{r.kind}</td>381 <td className="mono text-xs text-ink-3">{r.old_fingerprint ?? "∅"}</td>382 <td className="mono text-xs">{r.new_fingerprint}</td>383 <td>384 <button type="button" onClick={() => call(`/v1/admin/connectors/${r.connector_id}/schema-changes/${r.id}/ack`, { method: "POST", body: "{}" }).then(() => setRows((x) => x.filter((y) => y.id !== r.id)))} className="h-7 rounded border border-rule px-2 text-[11px]">385 acknowledge386 </button>387 </td>388 </tr>389 ))}390 </tbody>391 </table>392 </div>393 );394}395396function DivergenceTab({ call }: { call: Call }) {397 const [rows, setRows] = useState<Array<Record<string, any>>>([]);398 useEffect(() => {399 call<Array<Record<string, any>>>("/v1/admin/divergence").then(setRows).catch(() => {});400 }, [call]);401 return (402 <div className="overflow-x-auto rounded-md border border-rule bg-surface">403 <table className="table-dense">404 <thead>405 <tr>406 <th>Time</th>407 <th>Instruments</th>408 <th>Title</th>409 <th className="text-right">Dispersion</th>410 <th>Sources</th>411 </tr>412 </thead>413 <tbody>414 {rows.length === 0 && (415 <tr>416 <td colSpan={5} className="py-6 text-center text-ink-3">417 No divergence events recorded.418 </td>419 </tr>420 )}421 {rows.map((r) => (422 <tr key={r.id}>423 <td className="mono text-xs">{formatDateTime(r.ts, { seconds: true })}</td>424 <td className="mono text-xs">{(r.instrument_ids as string[]).join(", ")}</td>425 <td>{r.title}</td>426 <td className="num">{r.data?.dispersionBps?.toFixed?.(0) ?? "—"} bps</td>427 <td className="mono text-[11px] text-ink-3">{(r.data?.contributions as Array<{ source: string; value: number }> | undefined)?.map((c) => `${c.source}=${c.value}`).join(" · ")}</td>428 </tr>429 ))}430 </tbody>431 </table>432 </div>433 );434}435436function StorageTab({ call }: { call: Call }) {437 const [s, setS] = useState<Record<string, any> | null>(null);438 useEffect(() => {439 call<Record<string, any>>("/v1/admin/storage").then(setS).catch(() => {});440 }, [call]);441 if (!s) return <p className="text-sm text-ink-3">Loading…</p>;442 return (443 <div className="space-y-4">444 <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-4">445 <Stat label="Database" value={`${formatCompact(s.database_bytes)}B`} />446 <Stat label="Retention" value={`${s.retention_days} d`} sub="observation partitions" />447 <Stat label="Partitions" value={(s.partitions as unknown[]).length} />448 <Stat label="Archives" value={(s.archives as unknown[]).length} sub={s.data_dir} />449 </div>450 <div className="grid grid-cols-1 [&>*]:min-w-0 gap-4 lg:grid-cols-2">451 <div className="overflow-x-auto rounded-md border border-rule bg-surface">452 <table className="table-dense">453 <thead>454 <tr>455 <th>Partition</th>456 <th className="text-right">Size</th>457 <th className="text-right">≈ rows</th>458 </tr>459 </thead>460 <tbody>461 {(s.partitions as Array<Record<string, any>>).map((p) => (462 <tr key={p.relname}>463 <td className="mono">{p.relname}</td>464 <td className="num">{formatCompact(p.bytes)}B</td>465 <td className="num">{formatCompact(Math.max(0, Number(p.est_rows)))}</td>466 </tr>467 ))}468 </tbody>469 </table>470 </div>471 <div className="overflow-x-auto rounded-md border border-rule bg-surface">472 <table className="table-dense">473 <thead>474 <tr>475 <th>Archived day</th>476 <th className="text-right">Rows</th>477 <th className="text-right">Bytes</th>478 </tr>479 </thead>480 <tbody>481 {(s.archives as Array<Record<string, any>>).length === 0 && (482 <tr>483 <td colSpan={3} className="py-6 text-center text-ink-3">484 Nothing archived yet.485 </td>486 </tr>487 )}488 {(s.archives as Array<Record<string, any>>).map((a) => (489 <tr key={a.partition_name}>490 <td className="mono">{String(a.day).slice(0, 10)}</td>491 <td className="num">{formatCompact(a.rows_archived)}</td>492 <td className="num">{formatCompact(a.bytes)}B</td>493 </tr>494 ))}495 </tbody>496 </table>497 </div>498 </div>499 </div>500 );501}502503function DiscoveryTab({ call }: { call: Call }) {504 const [url, setUrl] = useState("");505 const [report, setReport] = useState<Record<string, any> | null>(null);506 const [busy, setBusy] = useState(false);507 return (508 <div className="space-y-4">509 <form510 className="flex gap-2"511 onSubmit={async (e) => {512 e.preventDefault();513 setBusy(true);514 setReport(null);515 try {516 setReport(await call<Record<string, any>>("/v1/admin/discovery", { method: "POST", body: JSON.stringify({ url }) }));517 } catch {518 /* error shown above */519 } finally {520 setBusy(false);521 }522 }}523 >524 <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/markets/AAPL" className="mono h-11 flex-1 rounded-md border border-rule bg-surface px-3 text-sm outline-none focus:border-rule-strong" />525 <button type="submit" disabled={busy || !url} className="h-11 rounded-md bg-ink px-4 text-sm font-medium text-canvas disabled:opacity-50">526 {busy ? "Probing…" : "Discover"}527 </button>528 </form>529 <p className="text-xs text-ink-3">Network-level prototype: fetches the public page unauthenticated (SSRF-guarded), inventories referenced JSON/WebSocket/SSE endpoints and embedded state, and scores candidate quote sources. Output is a candidate report for rights review — nothing is activated.</p>530 {report && (531 <div className="space-y-3 rounded-md border border-rule bg-surface p-4 text-sm">532 <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">533 <Stat label="HTTP" value={report.status} sub={report.content_type} />534 <Stat label="Bytes" value={formatCompact(report.bytes)} />535 <Stat label="WebSocket URLs" value={report.websocket_urls?.length ?? 0} />536 <Stat label="JSON candidates" value={report.json_endpoints?.length ?? 0} />537 </div>538 {report.websocket_urls?.length > 0 && (539 <div>540 <div className="text-[11px] font-medium uppercase tracking-wide text-ink-3">WebSocket endpoints</div>541 <ul className="mono text-xs">{(report.websocket_urls as string[]).map((u) => <li key={u}>{u}</li>)}</ul>542 </div>543 )}544 {report.json_endpoints?.length > 0 && (545 <div className="overflow-x-auto">546 <table className="table-dense">547 <thead>548 <tr>549 <th>Candidate endpoint</th>550 <th className="text-right">Score</th>551 <th>Hints</th>552 </tr>553 </thead>554 <tbody>555 {(report.json_endpoints as Array<Record<string, any>>).map((e) => (556 <tr key={e.url}>557 <td className="mono max-w-[520px] truncate text-xs">{e.url}</td>558 <td className="num">{e.score}</td>559 <td className="text-xs text-ink-2">{(e.hints as string[]).join(", ")}</td>560 </tr>561 ))}562 </tbody>563 </table>564 </div>565 )}566 {report.embedded_state?.length > 0 && (567 <div>568 <div className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Embedded state</div>569 {(report.embedded_state as Array<Record<string, any>>).map((s) => (570 <div key={s.kind} className="mt-1 text-xs">571 <span className="mono font-medium">{s.kind}</span> · fingerprint <span className="mono">{s.fingerprint}</span>572 {s.price_like_fields?.length > 0 && <div className="text-ink-2">price-like: {s.price_like_fields.join(", ")}</div>}573 {s.symbol_like_fields?.length > 0 && <div className="text-ink-2">symbol-like: {s.symbol_like_fields.join(", ")}</div>}574 </div>575 ))}576 </div>577 )}578 <ul className="list-disc pl-5 text-xs text-ink-2">{(report.notes as string[]).map((n) => <li key={n}>{n}</li>)}</ul>579 </div>580 )}581 </div>582 );583}584