SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
25.1 KB · 584 lines tsx
Raw Blame History
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