spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import Link from "next/link";4import { useState } from "react";5import { Badge, Button, ConfidenceBadge, EmptyState, EventBadge, RelativeTime, ScopeBadge, SemanticsLabel, Sheet, SkeletonBlock, SkeletonRows, Tab, TabList, TabPanel, Tabs, Table, TBody, Td, Th, THead, Tr } from "@/components/ui";6import { cn } from "@/components/ui/cn";7import { DASH, fmtDateTimeUtc, fmtInt, fmtValue } from "@/lib/format";8import { CLAIM_STATUS_LABEL, ENTITY_TYPE_LABEL } from "@/lib/labels";9import { entityHref } from "@/lib/routes";10import type { AdminRun, RollbackResult, RunChanges } from "@/lib/admin-api";11import { durationBetween, ErrorNote, ExtLink, fmtMs, JsonView, KV, Mono, RefreshButton, RunStatusBadge, Stat } from "./primitives";12import { AdminPageHeader } from "./shell";13import { useToast } from "./toast";14import { useAdminMutation, useAdminQuery } from "./use-admin-query";1516/* ------------------------------------------------------------------------------------------17 /admin/runs/[id] — one connector run: stats, log, everything it wrote (provenance, claims, events,18 document versions) and a ROLLBACK with a confirm dialog. Rollback rejects the run's claims, retires19 its provenance rows (winner restored to the latest remaining row per field) and rejects its events;20 entity columns are re-derived by the worker's next reconciliation.21 ------------------------------------------------------------------------------------------ */2223function n(v: unknown): number | null {24 return typeof v === "number" && Number.isFinite(v) ? v : null;25}2627export function RunDetailView({ id }: { id: string }) {28 const q = useAdminQuery<AdminRun>(`runs/${encodeURIComponent(id)}`, { timeoutMs: 30_000 });29 const ch = useAdminQuery<RunChanges>(`runs/${encodeURIComponent(id)}/changes`, { timeoutMs: 60_000 });30 const { busy, run } = useAdminMutation();31 const toast = useToast();32 const [confirm, setConfirm] = useState(false);33 const [rolled, setRolled] = useState<RollbackResult | null>(null);34 const r = q.data;35 const c = ch.data;36 const stats = (r?.stats ?? {}) as Record<string, unknown>;37 const capped = Boolean(c && Object.values(c.counts).some((v) => v >= 2000));3839 const rollback = async () => {40 const res = await run<RollbackResult>("rollback", `runs/${encodeURIComponent(id)}/rollback`, { body: {} });41 setConfirm(false);42 if (res.ok) {43 setRolled(res.data);44 toast({ tone: "ok", title: "Run rolled back", description: `${fmtInt(res.data.claimsRejected)} claims rejected · ${fmtInt(res.data.provenanceRetired)} provenance rows retired · ${fmtInt(res.data.winnersRestored)} winners restored · ${fmtInt(res.data.eventsRejected)} events rejected`, duration: 9000 });45 void ch.refresh();46 } else toast({ tone: "danger", title: "Rollback failed", description: res.error });47 };4849 if (q.error && !r) {50 return (51 <div>52 <AdminPageHeader eyebrow="Run" title={<span className="font-mono">{id}</span>} />53 <ErrorNote error={q.status === 404 ? "Run not found" : q.error} onRetry={() => void q.refresh()} />54 </div>55 );56 }5758 const statKeys = ["discovered", "selected", "fetched", "notModified", "changed", "unchanged", "extracted", "entities", "valid", "rejected", "created", "updated", "merged", "pendingMatches", "events", "provenanceRows", "claims", "failed", "robotsBlocked", "quarantined", "credits"];5960 return (61 <div>62 <AdminPageHeader63 eyebrow={64 <span className="inline-flex flex-wrap items-center gap-1.5">65 <Link href="/admin/connectors" className="hover:text-ink">66 Connectors67 </Link>68 {r && (69 <>70 <span>/</span>71 <Link href={`/admin/connectors/${encodeURIComponent(r.connectorId)}`} className="font-mono hover:text-ink">72 {r.connectorId}73 </Link>74 </>75 )}76 <span>/ run</span>77 </span>78 }79 title={<span className="font-mono text-[16px] sm:text-[18px]">{id}</span>}80 description={81 r ? (82 <span className="inline-flex flex-wrap items-center gap-1.5">83 <RunStatusBadge status={r.status} />84 <Badge size="xs" mono tone="outline">85 {r.task}86 </Badge>87 <span>88 started {r.startedAt ? <RelativeTime iso={r.startedAt} /> : DASH} · {fmtMs(durationBetween(r.startedAt, r.finishedAt))}89 </span>90 {r.quarantined && (91 <Badge size="xs" tone="warn">92 quarantined (nothing published)93 </Badge>94 )}95 </span>96 ) : undefined97 }98 aside={99 <>100 <RefreshButton onClick={() => void Promise.all([q.refresh(), ch.refresh()])} busy={q.refreshing || ch.refreshing} updatedAt={q.updatedAt} />101 <Button size="sm" variant="danger" onClick={() => setConfirm(true)} disabled={busy !== null || !c || rolled !== null} title="Reject this run's claims and events, retire its provenance rows">102 {rolled ? "Rolled back" : "Roll back this run"}103 </Button>104 </>105 }106 />107 {r?.error && <ErrorNote error={`Run error: ${r.error}`} className="mb-3" />}108 {rolled && (109 <div className="mb-3 rounded-xs border border-ok/30 bg-ok/10 px-2.5 py-2 text-[12.5px] text-ok">110 Rolled back: {fmtInt(rolled.claimsRejected)} claims rejected · {fmtInt(rolled.provenanceRetired)} provenance rows retired · {fmtInt(rolled.winnersRestored)} winners restored · {fmtInt(rolled.eventsRejected)} events rejected. Entity columns are re-derived by the worker’s next reconciliation.111 </div>112 )}113114 {!r ? (115 <SkeletonBlock className="mb-3 h-20" />116 ) : (117 <div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">118 {statKeys119 .filter((k) => n(stats[k]) !== null)120 .slice(0, 16)121 .map((k) => (122 <Stat key={k} label={k.replace(/([A-Z])/g, " $1").toLowerCase()} value={fmtInt(n(stats[k]))} tone={k === "failed" || k === "rejected" ? ((n(stats[k]) ?? 0) > 0 ? "danger" : undefined) : k === "created" && (n(stats[k]) ?? 0) > 0 ? "ok" : undefined} />123 ))}124 </div>125 )}126127 <div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">128 <div className="min-w-0 rounded-sm border border-hair bg-surface">129 <div className="flex flex-wrap items-center justify-between gap-2 border-b border-hair px-3 py-2">130 <div>131 <h2 className="text-[13.5px] font-semibold text-ink">What this run wrote</h2>132 <p className="text-[12px] text-ink-3">Rows tagged run_id = {id}{capped ? " — lists capped at 2 000 rows per kind" : ""}.</p>133 </div>134 {c && (135 <div className="figure text-[12px] text-ink-3">136 {fmtInt(c.counts.provenance)} provenance · {fmtInt(c.counts.claims)} claims · {fmtInt(c.counts.events)} events · {fmtInt(c.counts.documentVersions)} versions137 </div>138 )}139 </div>140 <ErrorNote error={ch.error && !c ? ch.error : null} onRetry={() => void ch.refresh()} className="m-3" />141 {!c && !ch.error ? (142 <div className="p-3">143 <SkeletonRows rows={6} />144 </div>145 ) : c ? (146 <Tabs defaultValue="provenance" className="px-3 pb-3">147 <TabList label="Run changes">148 <Tab value="provenance" count={c.counts.provenance}>149 Provenance150 </Tab>151 <Tab value="claims" count={c.counts.claims}>152 Claims153 </Tab>154 <Tab value="events" count={c.counts.events}>155 Events156 </Tab>157 <Tab value="versions" count={c.counts.documentVersions}>158 Document versions159 </Tab>160 </TabList>161 <TabPanel value="provenance">162 {c.provenance.length === 0 ? (163 <EmptyState compact title="No provenance row written" />164 ) : (165 <div className="max-w-full overflow-x-auto">166 <Table className="min-w-[760px] text-[12.5px]" dense responsive={false}>167 <THead>168 <Tr>169 <Th>Entity</Th>170 <Th>Field</Th>171 <Th>Value</Th>172 <Th>Source</Th>173 <Th>State</Th>174 <Th>Observed</Th>175 </Tr>176 </THead>177 <TBody>178 {c.provenance.map((p, i) => (179 <Tr key={`${p.entityId}-${p.field}-${i}`} className={cn(!p.isCurrent && "text-ink-3")}>180 <Td>181 <Link href={entityHref(p.entityType, p.entityId)} target="_blank" className="font-mono text-[12px] text-ink hover:text-accent">182 {p.entityId}183 </Link>184 <div className="text-[10.5px] text-ink-4">{ENTITY_TYPE_LABEL[p.entityType] ?? p.entityType}</div>185 </Td>186 <Td>187 <Mono>{p.field}</Mono>188 {p.scope && <ScopeBadge scope={p.scope} className="ml-1" />}189 </Td>190 <Td className="max-w-[240px] truncate figure" title={fmtValue(p.value)}>191 {fmtValue(p.value)}192 {p.isEstimate && <span className="ml-1 text-ink-4">≈</span>}193 </Td>194 <Td>195 <ExtLink href={p.url}>{p.sourceName}</ExtLink>196 <div className="text-[10.5px] text-ink-4">197 {p.sourceKind}198 {p.method && ` · ${p.method}`}199 </div>200 </Td>201 <Td>202 <div className="flex flex-wrap gap-1">203 {p.isWinner && (204 <Badge size="xs" tone="accent">205 winner206 </Badge>207 )}208 <Badge size="xs" tone={p.isCurrent ? "ok" : "outline"}>209 {p.isCurrent ? "current" : "retired"}210 </Badge>211 <ConfidenceBadge level={p.confidence} size="xs" />212 </div>213 </Td>214 <Td muted className="figure text-[11.5px]">215 {fmtDateTimeUtc(p.lastObserved)}216 </Td>217 </Tr>218 ))}219 </TBody>220 </Table>221 </div>222 )}223 </TabPanel>224 <TabPanel value="claims">225 {c.claims.length === 0 ? (226 <EmptyState compact title="No claim written" />227 ) : (228 <div className="max-w-full overflow-x-auto">229 <Table className="min-w-[760px] text-[12.5px]" dense responsive={false}>230 <THead>231 <Tr>232 <Th>Claim</Th>233 <Th>Predicate</Th>234 <Th numeric>Value</Th>235 <Th>Scope</Th>236 <Th>Status</Th>237 <Th>Source</Th>238 </Tr>239 </THead>240 <TBody>241 {c.claims.map((k) => (242 <Tr key={k.id} className={cn(k.status !== "current" && "text-ink-3")}>243 <Td>244 <Mono>{k.id}</Mono>245 {k.isWinner && (246 <Badge size="xs" tone="accent" className="ml-1">247 shown248 </Badge>249 )}250 </Td>251 <Td>252 <SemanticsLabel semantics={k.predicate} className="text-[12px]" />253 </Td>254 <Td numeric className="figure">255 {k.value !== null ? fmtValue(k.value, k.unit === "USD" ? "usd" : "mw") : k.valueText ?? DASH}256 </Td>257 <Td>258 <ScopeBadge scope={k.scope} />259 </Td>260 <Td>261 <Badge size="xs" tone={k.status === "current" ? "ok" : k.status === "rejected" ? "danger" : k.status === "review" ? "warn" : "outline"}>262 {CLAIM_STATUS_LABEL[k.status]}263 </Badge>264 {k.rejectionReason && <div className="text-[10.5px] text-ink-4">{k.rejectionReason}</div>}265 </Td>266 <Td>267 <ExtLink href={k.url}>{k.sourceName}</ExtLink>268 </Td>269 </Tr>270 ))}271 </TBody>272 </Table>273 </div>274 )}275 </TabPanel>276 <TabPanel value="events">277 {c.events.length === 0 ? (278 <EmptyState compact title="No event written" />279 ) : (280 <ul className="divide-y divide-hair">281 {c.events.map((e) => (282 <li key={e.id} className="flex flex-wrap items-center gap-2 py-2 text-[12.5px]">283 <EventBadge type={e.eventType} size="xs" />284 {e.entity ? (285 <Link href={entityHref(e.entityType, e.entity.slug, e.entity.name)} target="_blank" className="font-medium text-ink hover:text-accent hover:underline">286 {e.entity.name}287 </Link>288 ) : (289 <span className="font-medium text-ink">{e.title}</span>290 )}291 {e.entity && e.title !== e.entity.name && <span className="text-ink-2">— {e.title}</span>}292 <Badge size="xs" tone={e.reviewStatus === "rejected" ? "danger" : "outline"}>293 {e.reviewStatus}294 </Badge>295 <span className="figure text-ink-4">sig {e.significance}</span>296 <Mono className="ml-auto text-ink-4">{e.id}</Mono>297 </li>298 ))}299 </ul>300 )}301 </TabPanel>302 <TabPanel value="versions">303 {c.documentVersions.length === 0 ? (304 <EmptyState compact title="No document version written" />305 ) : (306 <Table className="text-[12.5px]" dense>307 <THead>308 <Tr>309 <Th>Document</Th>310 <Th>Version</Th>311 <Th>Fetched</Th>312 <Th numeric>Significance</Th>313 <Th numeric>Changes</Th>314 </Tr>315 </THead>316 <TBody>317 {c.documentVersions.map((v) => (318 <Tr key={v.id}>319 <Td>320 <Link href={`/admin/documents/${encodeURIComponent(v.documentId)}`} className="font-mono text-[12px] text-ink hover:text-accent">321 {v.documentId}322 </Link>323 {v.url && (324 <div className="max-w-[360px] truncate text-[11px] text-ink-4" title={v.url}>325 {v.url.replace(/^https?:\/\//, "")}326 </div>327 )}328 </Td>329 <Td>330 <Mono>{v.id}</Mono>331 </Td>332 <Td muted>{v.fetchedAt ? <RelativeTime iso={v.fetchedAt} /> : DASH}</Td>333 <Td numeric>{fmtInt(v.significance)}</Td>334 <Td numeric>{fmtInt(v.changes)}</Td>335 </Tr>336 ))}337 </TBody>338 </Table>339 )}340 </TabPanel>341 </Tabs>342 ) : null}343 </div>344345 <div className="min-w-0 space-y-3">346 <div className="rounded-sm border border-hair bg-surface">347 <div className="border-b border-hair px-3 py-2">348 <h2 className="text-[13.5px] font-semibold text-ink">Run</h2>349 </div>350 <div className="px-3 py-2">351 {!r ? (352 <SkeletonRows rows={5} />353 ) : (354 <KV355 items={[356 { k: "Connector", v: <Link href={`/admin/connectors/${encodeURIComponent(r.connectorId)}`} className="font-mono hover:text-accent">{r.connectorId}</Link> },357 { k: "Task", v: <Mono>{r.task}</Mono> },358 { k: "Status", v: <RunStatusBadge status={r.status} /> },359 { k: "Started", v: fmtDateTimeUtc(r.startedAt) },360 { k: "Finished", v: fmtDateTimeUtc(r.finishedAt) },361 { k: "Duration", v: fmtMs(durationBetween(r.startedAt, r.finishedAt)) },362 { k: "Avg fetch", v: n(stats.avgMs) !== null ? fmtMs(n(stats.avgMs)) : DASH },363 ]}364 />365 )}366 </div>367 </div>368 <div className="rounded-sm border border-hair bg-surface">369 <div className="border-b border-hair px-3 py-2">370 <h2 className="text-[13.5px] font-semibold text-ink">Stats</h2>371 </div>372 <div className="px-3 py-2">{r ? <JsonView value={r.stats} maxHeight={260} collapsible /> : <SkeletonRows rows={4} />}</div>373 </div>374 <div className="rounded-sm border border-hair bg-surface">375 <div className="border-b border-hair px-3 py-2">376 <h2 className="text-[13.5px] font-semibold text-ink">Log</h2>377 </div>378 {!r ? (379 <div className="px-3 py-2">380 <SkeletonRows rows={4} />381 </div>382 ) : (r.log ?? []).length === 0 ? (383 <p className="px-3 py-2 text-[12px] text-ink-4">No log lines recorded.</p>384 ) : (385 <div className="max-h-[420px] overflow-auto bg-paper font-mono text-[11.5px] leading-[1.5]">386 {(r.log ?? []).map((l, i) => (387 <div key={i} className={cn("flex gap-2 border-b border-hair/60 px-2 py-0.5 last:border-0", l.level === "error" && "bg-danger/10 text-danger", l.level === "warn" && "bg-warn/10 text-warn")}>388 <span className="shrink-0 text-ink-4">{l.t ? String(l.t).slice(11, 19) : ""}</span>389 <span className="w-10 shrink-0 text-ink-3">{l.level ?? ""}</span>390 <span className="min-w-0 break-all text-ink-2">{String(l.msg ?? JSON.stringify(l))}</span>391 </div>392 ))}393 </div>394 )}395 </div>396 </div>397 </div>398399 <Sheet400 open={confirm}401 onClose={() => setConfirm(false)}402 title="Roll back this run?"403 description={`Run ${id}${r ? ` · ${r.connectorId} · ${r.task}` : ""}`}404 heightVh={60}405 footer={406 <div className="flex items-center justify-between gap-2">407 <Button variant="ghost" onClick={() => setConfirm(false)} disabled={busy === "rollback"}>408 Cancel409 </Button>410 <Button variant="danger" onClick={() => void rollback()} disabled={busy === "rollback"}>411 {busy === "rollback" ? "Rolling back…" : "Yes, roll back"}412 </Button>413 </div>414 }415 >416 <div className="space-y-3 text-[13px] text-ink-2">417 <p>This will, for every row this run wrote:</p>418 <ul className="list-disc space-y-1 pl-5">419 <li>420 <strong className="text-ink">{c ? fmtInt(c.counts.claims) : "…"} claims</strong> → status <Mono>rejected</Mono> (reason “rollback”)421 </li>422 <li>423 <strong className="text-ink">{c ? fmtInt(c.counts.provenance) : "…"} provenance rows</strong> → retired; the winner of each affected field is restored to the latest remaining observation424 </li>425 <li>426 <strong className="text-ink">{c ? fmtInt(c.counts.events) : "…"} events</strong> → review status <Mono>rejected</Mono> (removed from the live feed)427 </li>428 </ul>429 <p className="text-[12px] text-ink-3">Entity columns (MW, status, coordinates…) are not rewritten here: the worker re-derives them on its next reconciliation. Document versions and archived bodies are kept. This action is recorded and cannot be undone from the console.</p>430 </div>431 </Sheet>432 </div>433 );434}435