SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
26.9 KB · 572 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useCallback, useEffect, useMemo, useState } from "react";5import { Badge, Button, ConfidenceBadge, EmptyState, RelativeTime, SearchInput, 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 { ENTITY_TYPE_LABEL } from "@/lib/labels";9import { entityHref } from "@/lib/routes";10import { adminFetchText, adminProxyUrl, type AdminDocumentDetail, type AdminDocVersion } from "@/lib/admin-api";11import { Code, countMatches, DiffView, ErrorNote, ExtLink, fmtBytes, JsonView, KV, LevelBadge, Mono, Panel, RefreshButton, Spinner, Stat } from "./primitives";12import { AdminPageHeader } from "./shell";13import { useToast } from "./toast";14import { useAdminMutation, useAdminQuery } from "./use-admin-query";1516const HTML_PREVIEW_CAP = 400_000;1718function isHtml(ct: string | null | undefined, body: string): boolean {19  return /html|xml/i.test(ct ?? "") || /<html|<!doctype html/i.test(body.slice(0, 2000));20}2122/** Text projection of an HTML body (browser DOMParser; scripts/styles dropped, block elements → newlines). */23export function htmlToTextClient(html: string): string {24  try {25    const doc = new DOMParser().parseFromString(html, "text/html");26    doc.querySelectorAll("script,style,noscript,template,svg").forEach((n) => n.remove());27    const blocks = new Set(["P", "DIV", "LI", "TR", "H1", "H2", "H3", "H4", "H5", "H6", "BR", "SECTION", "ARTICLE", "HEADER", "FOOTER", "TD", "TH", "UL", "OL", "TABLE", "BLOCKQUOTE", "PRE", "DD", "DT", "FIGCAPTION", "NAV", "ASIDE", "MAIN"]);28    const out: string[] = [];29    const walk = (node: Node) => {30      if (node.nodeType === Node.TEXT_NODE) out.push(node.textContent ?? "");31      else if (node.nodeType === Node.ELEMENT_NODE) {32        const el = node as Element;33        const block = blocks.has(el.tagName);34        if (block) out.push("\n");35        el.childNodes.forEach(walk);36        if (block) out.push("\n");37      }38    };39    walk(doc.body ?? doc);40    return out41      .join("")42      .split("\n")43      .map((l) => l.replace(/\s+/g, " ").trim())44      .filter((l, i, arr) => l || (i > 0 && arr[i - 1]))45      .join("\n")46      .trim();47  } catch {48    return html;49  }50}5152/** Injects <base> so relative assets resolve; scripts are stripped and the frame is sandboxed. */53export function srcDocFor(html: string, baseUrl: string | null): string {54  const clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "").replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");55  const base = baseUrl ? `<base href="${baseUrl.replace(/"/g, "&quot;")}" target="_blank">` : "";56  const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data: blob:; style-src * 'unsafe-inline'; font-src * data:; media-src *;">`;57  if (/<head[^>]*>/i.test(clean)) return clean.replace(/<head[^>]*>/i, (m) => `${m}${csp}${base}`);58  return `<!doctype html><html><head>${csp}${base}</head><body>${clean}</body></html>`;59}6061interface RawBody {62  loading: boolean;63  text: string;64  contentType: string | null;65  truncated: boolean;66  error: string | null;67}6869/** Raw body for (document, version) through the proxy. `loading` is derived from the result key (no setState in the effect body). */70function useRawBody(docId: string, version: string | null, enabled: boolean): RawBody {71  const key = `${docId}|${version ?? ""}`;72  const [slot, setSlot] = useState<{ key: string; text: string; contentType: string | null; truncated: boolean; error: string | null }>({ key: "", text: "", contentType: null, truncated: false, error: null });73  useEffect(() => {74    if (!enabled) return;75    let alive = true;76    void adminFetchText(`documents/${encodeURIComponent(docId)}/raw`, version ? { version } : undefined).then((r) => {77      if (!alive) return;78      setSlot({ key, text: r.ok ? r.text : "", contentType: r.contentType, truncated: r.truncated, error: r.ok ? null : (r.error ?? "failed") });79    });80    return () => {81      alive = false;82    };83  }, [docId, version, enabled, key]);84  const current = slot.key === key;85  return { loading: enabled && !current, text: current ? slot.text : "", contentType: current ? slot.contentType : null, truncated: current && slot.truncated, error: current ? slot.error : null };86}8788export function DocumentInspectView({ id }: { id: string }) {89  const q = useAdminQuery<AdminDocumentDetail>(`documents/${encodeURIComponent(id)}`, { timeoutMs: 30_000 });90  const { busy, run } = useAdminMutation();91  const toast = useToast();92  const d = q.data?.document;93  const versions = useMemo(() => q.data?.versions ?? [], [q.data]);9495  // A defaults to the newest version, B to the previous one, until the user picks96  const [pickA, setPickA] = useState<string | null>(null);97  const [pickB, setPickB] = useState<string | null>(null);98  const selA = pickA ?? versions[0]?.id ?? null;99  const selB = pickB ?? versions[1]?.id ?? null;100  const [diffMode, setDiffMode] = useState<"text" | "html">("text");101  const rawA = useRawBody(id, selA, Boolean(selA && selB));102  const rawB = useRawBody(id, selB, Boolean(selA && selB));103  const newer = useMemo(() => versions.find((v) => v.id === selA) ?? null, [versions, selA]);104  const older = useMemo(() => versions.find((v) => v.id === selB) ?? null, [versions, selB]);105  const [aText, bText] = useMemo(() => {106    const proj = (raw: typeof rawA) => (raw.text ? (diffMode === "text" && isHtml(raw.contentType, raw.text) ? htmlToTextClient(raw.text) : raw.text) : "");107    return [proj(rawA), proj(rawB)];108  }, [rawA, rawB, diffMode]);109  // order the diff old → new regardless of which radio was picked110  const [oldText, newText, oldLabel, newLabel] = useMemo(() => {111    const ta = newer?.fetchedAt ? new Date(newer.fetchedAt).getTime() : 0;112    const tb = older?.fetchedAt ? new Date(older.fetchedAt).getTime() : 0;113    return ta >= tb ? [bText, aText, older?.fetchedAt ?? "", newer?.fetchedAt ?? ""] : [aText, bText, newer?.fetchedAt ?? "", older?.fetchedAt ?? ""];114  }, [aText, bText, newer, older]);115  const changesVersion = useMemo(() => {116    if (!newer && !older) return null;117    const ta = newer?.fetchedAt ? new Date(newer.fetchedAt).getTime() : 0;118    const tb = older?.fetchedAt ? new Date(older.fetchedAt).getTime() : 0;119    return ta >= tb ? newer : older;120  }, [newer, older]);121122  // raw viewer123  const [viewVersion, setViewVersion] = useState<string | null>(null);124  const [viewTab, setViewTab] = useState("rendered");125  const [search, setSearch] = useState("");126  const raw = useRawBody(id, viewVersion, Boolean(d));127  const rawIsHtml = isHtml(raw.contentType ?? d?.contentType, raw.text);128  const rawIsPdf = /pdf/i.test(raw.contentType ?? d?.contentType ?? "");129  const rawIsJson = /json/i.test(raw.contentType ?? d?.contentType ?? "");130  const textProjection = useMemo(() => (rawIsHtml ? htmlToTextClient(raw.text) : raw.text), [raw.text, rawIsHtml]);131  const srcDoc = useMemo(() => (rawIsHtml ? srcDocFor(raw.text.slice(0, 2_000_000), d?.canonicalUrl ?? d?.url ?? null) : ""), [raw.text, rawIsHtml, d]);132  const jsonValue = useMemo(() => {133    if (!rawIsJson) return null;134    try {135      return JSON.parse(raw.text) as unknown;136    } catch {137      return null;138    }139  }, [raw.text, rawIsJson]);140141  const act = useCallback(142    async (action: "reprocess" | "refetch" | "quarantine", body?: unknown) => {143      const r = await run<{ enqueued?: boolean; job?: { id?: string }; quarantined?: boolean }>(action, `documents/${encodeURIComponent(id)}/${action}`, { body: body ?? {} });144      if (r.ok) {145        if (action === "quarantine") {146          q.mutate((prev) => (prev ? { ...prev, document: { ...prev.document, quarantined: Boolean(r.data.quarantined) } } : prev));147          toast({ tone: "ok", title: r.data.quarantined ? "Document quarantined" : "Quarantine cleared" });148        } else toast({ tone: "ok", title: action === "reprocess" ? "Reprocess enqueued" : "Forced refetch enqueued", description: r.data.job?.id ? `job ${r.data.job.id}` : undefined });149      } else toast({ tone: "danger", title: `${action} failed`, description: r.error });150    },151    [id, run, q, toast],152  );153154  if (q.error && !q.data) {155    return (156      <div>157        <AdminPageHeader eyebrow="Document" title={<span className="font-mono">{id}</span>} />158        <ErrorNote error={q.status === 404 ? "Document not found" : q.error} onRetry={() => void q.refresh()} />159      </div>160    );161  }162163  return (164    <div>165      <AdminPageHeader166        eyebrow={167          <span className="inline-flex flex-wrap items-center gap-1.5">168            <Link href="/admin/documents" className="hover:text-ink">169              Documents170            </Link>171            {d && (172              <>173                <span>/</span>174                <Link href={`/admin/connectors/${encodeURIComponent(d.connectorId)}`} className="font-mono hover:text-ink">175                  {d.connectorId}176                </Link>177              </>178            )}179          </span>180        }181        title={d ? <span className="break-all font-mono text-[15px] sm:text-[17px]">{d.url.replace(/^https?:\/\//, "")}</span> : <span className="font-mono">{id}</span>}182        description={183          d ? (184            <span className="inline-flex flex-wrap items-center gap-1.5">185              <ExtLink href={d.url}>open</ExtLink>186              <Badge size="xs" mono tone="outline">187                {d.pageType}188              </Badge>189              <LevelBadge level={d.fetchLevel} />190              {d.statusCode != null && (191                <Badge size="xs" mono tone={d.statusCode >= 400 ? "danger" : "neutral"}>192                  HTTP {d.statusCode}193                </Badge>194              )}195              {d.quarantined && (196                <Badge size="xs" tone="warn">197                  quarantined198                </Badge>199              )}200              {d.extractOk === false && (201                <Badge size="xs" tone="danger">202                  extraction failed203                </Badge>204              )}205              <Mono className="text-ink-4">{d.id}</Mono>206            </span>207          ) : undefined208        }209        aside={210          d ? (211            <>212              <RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />213              <Button size="sm" onClick={() => void act("reprocess")} disabled={busy !== null}>214                Reprocess215              </Button>216              <Button size="sm" onClick={() => void act("refetch")} disabled={busy !== null}>217                Refetch (force)218              </Button>219              <Button size="sm" variant={d.quarantined ? "secondary" : "danger"} onClick={() => void act("quarantine", { quarantined: !d.quarantined })} disabled={busy !== null}>220                {d.quarantined ? "Unquarantine" : "Quarantine"}221              </Button>222            </>223          ) : undefined224        }225      />226      {d?.error && <ErrorNote error={`Last error (×${d.errorCount}): ${d.error}`} className="mb-3" />}227228      {!d ? (229        <SkeletonBlock className="mb-3 h-16" />230      ) : (231        <div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">232          <Stat label="Versions" value={fmtInt(d.versionCount ?? versions.length)} />233          <Stat label="Fetches" value={fmtInt(d.fetchCount)} note={`${fmtInt(d.changeCount)} changed`} />234          <Stat label="Extracted" value={d.extractOk == null ? DASH : d.extractOk ? fmtInt(d.extractCount) : "failed"} tone={d.extractOk === false ? "danger" : undefined} note={d.extractorVersion ? `extractor ${d.extractorVersion}` : undefined} />235          <Stat label="Size" value={fmtBytes(d.sizeBytes)} note={d.contentType ?? undefined} />236          <Stat label="Last fetched" value={<RelativeTime iso={d.lastFetched} />} note={d.lastChanged ? <span>changed <RelativeTime iso={d.lastChanged} /></span> : undefined} />237          <Stat label="Next check" value={d.nextCheck ? <RelativeTime iso={d.nextCheck} /> : d.quarantined ? "quarantined" : DASH} note={d.changeFrequencyScore != null ? `change freq ${d.changeFrequencyScore.toFixed(2)}` : undefined} />238        </div>239      )}240241      <div className="grid gap-3 lg:grid-cols-3">242        <Panel title="Metadata" className="lg:col-span-1">243          {!d ? (244            <SkeletonRows rows={8} />245          ) : (246            <KV247              items={[248                { k: "Canonical URL", v: <ExtLink href={d.canonicalUrl} /> },249                { k: "Title", v: d.title, title: d.title ?? undefined },250                { k: "Classifier", v: d.classifier ? <Mono title={d.classifier}>{d.classifier}</Mono> : DASH },251                { k: "Priority", v: fmtInt(d.priority) },252                { k: "Content hash", v: d.contentHash ? <Mono title={d.contentHash}>{d.contentHash.slice(0, 16)}…</Mono> : DASH },253                { k: "ETag", v: d.etag ? <Mono>{d.etag}</Mono> : DASH },254                { k: "Last-Modified", v: d.lastModified },255                { k: "Storage key", v: d.storageKey ? <Mono title={d.storageKey}>{d.storageKey}</Mono> : DASH },256                { k: "Source", v: <Mono>{d.sourceId}</Mono> },257                { k: "Discovered from", v: d.discoveredFrom ? <Mono title={d.discoveredFrom}>{d.discoveredFrom}</Mono> : DASH },258                { k: "First seen", v: fmtDateTimeUtc(d.firstSeen) },259                { k: "Last checked", v: fmtDateTimeUtc(d.lastChecked) },260                { k: "Updated", v: fmtDateTimeUtc(d.updatedAt) },261              ]}262            />263          )}264        </Panel>265266        <Panel title="Versions" description="Pick two versions to diff (A = newer by default)" className="lg:col-span-2" padded={false}>267          {!q.data ? (268            <div className="p-3">269              <SkeletonRows rows={3} />270            </div>271          ) : versions.length === 0 ? (272            <EmptyState compact title="No stored versions" description="The body has not been archived yet." />273          ) : (274            <div className="max-h-[320px] overflow-auto">275              <Table responsive={false} className="text-[12.5px]">276                <THead>277                  <Tr>278                    <Th className="pl-3">A</Th>279                    <Th>B</Th>280                    <Th>Fetched</Th>281                    <Th>Lvl</Th>282                    <Th numeric>HTTP</Th>283                    <Th numeric>Size</Th>284                    <Th numeric>Signif.</Th>285                    <Th numeric>Changes</Th>286                    <Th className="pr-3">Hash</Th>287                  </Tr>288                </THead>289                <TBody>290                  {versions.map((v) => (291                    <Tr key={v.id} className={cn((v.id === selA || v.id === selB) && "bg-surface-2/60")}>292                      <Td className="pl-3">293                        <input type="radio" name="verA" aria-label="Select as A" checked={selA === v.id} onChange={() => setPickA(v.id)} className="size-4 accent-[var(--accent)]" />294                      </Td>295                      <Td>296                        <input type="radio" name="verB" aria-label="Select as B" checked={selB === v.id} onChange={() => setPickB(v.id)} className="size-4 accent-[var(--ink)]" />297                      </Td>298                      <Td>299                        <span title={fmtDateTimeUtc(v.fetchedAt)}>{fmtDateTimeUtc(v.fetchedAt)}</span>300                      </Td>301                      <Td>302                        <LevelBadge level={v.fetchLevel} />303                      </Td>304                      <Td numeric>{v.statusCode ?? DASH}</Td>305                      <Td numeric>{fmtBytes(v.sizeBytes)}</Td>306                      <Td numeric className={cn(v.significance >= 60 && "text-accent")}>307                        {v.significance}308                      </Td>309                      <Td numeric>{fmtInt(v.detectedChanges?.length ?? 0)}</Td>310                      <Td className="pr-3">311                        <Mono title={v.contentHash}>{v.contentHash.slice(0, 10)}</Mono>312                        <button type="button" className="ml-2 text-[11px] text-ink-3 hover:text-accent" onClick={() => { setViewVersion(v.id); setViewTab("source"); }}>313                          view314                        </button>315                      </Td>316                    </Tr>317                  ))}318                </TBody>319              </Table>320            </div>321          )}322        </Panel>323      </div>324325      {selA && selB && selA !== selB && (326        <Panel327          title="Diff"328          className="mt-3"329          description={`${newLabel ? fmtDateTimeUtc(oldLabel) : ""} → ${newLabel ? fmtDateTimeUtc(newLabel) : ""}`}330          aside={331            <div className="flex items-center gap-1">332              <Button size="xs" variant={diffMode === "text" ? "primary" : "ghost"} onClick={() => setDiffMode("text")}>333                Text334              </Button>335              <Button size="xs" variant={diffMode === "html" ? "primary" : "ghost"} onClick={() => setDiffMode("html")}>336                Raw337              </Button>338            </div>339          }340        >341          {rawA.loading || rawB.loading ? (342            <div className="flex items-center gap-2 py-6 text-[12.5px] text-ink-3">343              <Spinner /> Loading both bodies from storage…344            </div>345          ) : rawA.error || rawB.error ? (346            <ErrorNote error={rawA.error ?? rawB.error} />347          ) : (348            <div className="space-y-4">349              <DetectedChanges version={changesVersion} />350              <DiffView before={oldText} after={newText} context={3} />351            </div>352          )}353        </Panel>354      )}355356      <Panel357        title="Body"358        className="mt-3"359        description={raw.contentType ? <span>{raw.contentType}{raw.truncated ? " · truncated at 5 MB" : ""}{viewVersion ? ` · version ${viewVersion}` : " · current"}</span> : undefined}360        aside={361          <div className="flex flex-wrap items-center gap-2">362            {versions.length > 0 && (363              <select value={viewVersion ?? ""} onChange={(e) => setViewVersion(e.target.value || null)} className="h-8 rounded-sm border border-hair-2 bg-surface px-2 font-mono text-[12px] text-ink" aria-label="Body version">364                <option value="">current</option>365                {versions.map((v) => (366                  <option key={v.id} value={v.id}>367                    {fmtDateTimeUtc(v.fetchedAt)} · {v.contentHash.slice(0, 8)}368                  </option>369                ))}370              </select>371            )}372            <a href={adminProxyUrl(`documents/${encodeURIComponent(id)}/raw`, viewVersion ? { version: viewVersion } : undefined)} target="_blank" rel="noopener" className="text-[12px] text-ink-2 underline-offset-2 hover:text-accent hover:underline">373              download374            </a>375          </div>376        }377        padded={false}378      >379        {raw.loading ? (380          <div className="flex items-center gap-2 p-4 text-[12.5px] text-ink-3">381            <Spinner /> Loading body…382          </div>383        ) : raw.error ? (384          <div className="p-3">385            <ErrorNote error={`Raw body unavailable: ${raw.error}`} />386          </div>387        ) : rawIsPdf ? (388          <iframe title="PDF" src={adminProxyUrl(`documents/${encodeURIComponent(id)}/raw`, viewVersion ? { version: viewVersion } : undefined)} className="h-[70vh] w-full bg-white" />389        ) : (390          <Tabs value={viewTab} onValueChange={setViewTab}>391            <div className="flex flex-col gap-2 border-b border-hair px-3 pt-1 sm:flex-row sm:items-center">392              <TabList label="Body views" className="border-b-0">393                {rawIsHtml && <Tab value="rendered">Rendered</Tab>}394                <Tab value="source">Source</Tab>395                <Tab value="text">Text</Tab>396                {jsonValue !== null && <Tab value="json">JSON</Tab>}397              </TabList>398              {(viewTab === "source" || viewTab === "text") && (399                <div className="flex items-center gap-2 pb-2 sm:ml-auto sm:pb-0">400                  <SearchInput size="sm" value={search} onChange={setSearch} placeholder="Find in body" wrapperClassName="w-[220px]" />401                  {search && <span className="figure text-[11.5px] text-ink-3">{fmtInt(countMatches(viewTab === "source" ? raw.text : textProjection, search))} hits</span>}402                </div>403              )}404            </div>405            {rawIsHtml && (406              <TabPanel value="rendered">407                <iframe title="Rendered document (sandboxed)" sandbox="" srcDoc={srcDoc} className="h-[70vh] w-full bg-white" />408              </TabPanel>409            )}410            <TabPanel value="source" className="p-2">411              <Code text={raw.text.length > HTML_PREVIEW_CAP ? `${raw.text.slice(0, HTML_PREVIEW_CAP)}\n… (${fmtBytes(raw.text.length)} total, showing first ${fmtBytes(HTML_PREVIEW_CAP)})` : raw.text} lang={rawIsHtml ? "html" : "text"} search={search} maxHeight={560} wrap />412            </TabPanel>413            <TabPanel value="text" className="p-2">414              <Code text={textProjection.slice(0, HTML_PREVIEW_CAP)} lang="text" search={search} maxHeight={560} wrap />415            </TabPanel>416            {jsonValue !== null && (417              <TabPanel value="json" className="p-2">418                <JsonView value={jsonValue} maxHeight={560} />419              </TabPanel>420            )}421          </Tabs>422        )}423      </Panel>424425      <div className="mt-3 grid gap-3 lg:grid-cols-3">426        <Panel title="Extracted entities" description="entity_refs on the document" padded={false}>427          {!q.data ? (428            <div className="p-3">429              <SkeletonRows rows={2} />430            </div>431          ) : q.data.entities.length === 0 ? (432            <EmptyState compact title="No entities linked" />433          ) : (434            <ul className="divide-y divide-hair">435              {q.data.entities.map((e) => (436                <li key={`${e.type}:${e.id}`} className="flex items-center justify-between gap-2 px-3 py-2 text-[12.5px]">437                  <div className="min-w-0">438                    <Link href={entityHref(e.type, e.slug, e.name ?? undefined)} className="block truncate font-medium text-ink hover:text-accent hover:underline">439                      {e.name ?? e.id}440                    </Link>441                    <div className="text-[11.5px] text-ink-3">442                      {ENTITY_TYPE_LABEL[e.type] ?? e.type} · <Mono>{e.id}</Mono>443                    </div>444                  </div>445                  {e.type === "facility" && (446                    <Button size="xs" variant="ghost" href={`/admin/facilities/${encodeURIComponent(e.id)}/edit`}>447                      Curate448                    </Button>449                  )}450                </li>451              ))}452            </ul>453          )}454        </Panel>455        <Panel title="Provenance rows produced" description="Fields whose value was observed on this document" className="lg:col-span-2" padded={false}>456          {!q.data ? (457            <div className="p-3">458              <SkeletonRows rows={3} />459            </div>460          ) : q.data.provenance.length === 0 ? (461            <EmptyState compact title="No provenance rows reference this document" />462          ) : (463            <div className="max-h-[420px] overflow-auto">464              <Table responsive={false} className="text-[12.5px]">465                <THead>466                  <Tr>467                    <Th className="pl-3">Entity</Th>468                    <Th>Field</Th>469                    <Th>Value</Th>470                    <Th>Confidence</Th>471                    <Th>Method</Th>472                    <Th>Observed</Th>473                    <Th className="pr-3">Current</Th>474                  </Tr>475                </THead>476                <TBody>477                  {q.data.provenance.map((p, i) => (478                    <Tr key={i}>479                      <Td className="pl-3">480                        <Mono title={p.entityId}>481                          {p.entityType}:{p.entityId.slice(0, 14)}482                        </Mono>483                      </Td>484                      <Td>485                        <Mono className="text-ink">{p.field}</Mono>486                      </Td>487                      <Td className="max-w-[260px] truncate" title={JSON.stringify(p.value)}>488                        {fmtValue(p.value)}489                        {p.isEstimate && <span className="ml-1 text-warn">≈</span>}490                      </Td>491                      <Td>492                        <ConfidenceBadge level={p.confidence} size="xs" />493                      </Td>494                      <Td muted>{p.method ?? DASH}</Td>495                      <Td muted>496                        <RelativeTime iso={p.lastObserved} />497                      </Td>498                      <Td className="pr-3">{p.isCurrent ? <span className="text-ok">yes</span> : <span className="text-ink-4">no</span>}</Td>499                    </Tr>500                  ))}501                </TBody>502              </Table>503            </div>504          )}505        </Panel>506      </div>507    </div>508  );509}510511function DetectedChanges({ version }: { version: AdminDocVersion | null }) {512  if (!version) return null;513  const changes = version.detectedChanges ?? [];514  const s = version.diffSummary;515  return (516    <div className="space-y-2">517      <div className="flex flex-wrap items-center gap-3 text-[12.5px] text-ink-3">518        <span>519          Stored diff summary for version <Mono>{version.id}</Mono>:520        </span>521        {s ? (522          <span className="figure">523            +{fmtInt(s.addedCount ?? s.added ?? 0)} −{fmtInt(s.removedCount ?? s.removed ?? 0)} · ratio {typeof s.ratio === "number" ? s.ratio.toFixed(3) : DASH}524          </span>525        ) : (526          <span>none (first version)</span>527        )}528        <span>529          significance <span className={cn("figure", version.significance >= 60 ? "text-accent" : "text-ink-2")}>{version.significance}</span>530        </span>531      </div>532      {changes.length > 0 && (533        <Table responsive={false} className="text-[12.5px]">534          <THead>535            <Tr>536              <Th>Field</Th>537              <Th>Old</Th>538              <Th>New</Th>539              <Th>Event</Th>540              <Th numeric>Significance</Th>541            </Tr>542          </THead>543          <TBody>544            {changes.map((c, i) => (545              <Tr key={i}>546                <Td>547                  <Mono className="text-ink">{c.field}</Mono>548                  {c.description && <div className="text-[11.5px] text-ink-3">{c.description}</div>}549                </Td>550                <Td className="max-w-[220px] truncate text-danger" title={JSON.stringify(c.oldValue)}>551                  {fmtValue(c.oldValue)}552                </Td>553                <Td className="max-w-[220px] truncate text-st-operational" title={JSON.stringify(c.newValue)}>554                  {fmtValue(c.newValue)}555                </Td>556                <Td>557                  <Badge size="xs" mono tone="outline">558                    {c.eventType}559                  </Badge>560                </Td>561                <Td numeric className={cn(c.significance >= 60 && "text-accent")}>562                  {c.significance}563                </Td>564              </Tr>565            ))}566          </TBody>567        </Table>568      )}569    </div>570  );571}572