SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
yesterdaylast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
21.4 KB · 435 lines tsx
Raw Blame History
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&rsquo;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