spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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, """)}" 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