"use client"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Badge, Button, ConfidenceBadge, EmptyState, RelativeTime, SearchInput, SkeletonBlock, SkeletonRows, Tab, TabList, TabPanel, Tabs, Table, TBody, Td, Th, THead, Tr } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { DASH, fmtDateTimeUtc, fmtInt, fmtValue } from "@/lib/format"; import { ENTITY_TYPE_LABEL } from "@/lib/labels"; import { entityHref } from "@/lib/routes"; import { adminFetchText, adminProxyUrl, type AdminDocumentDetail, type AdminDocVersion } from "@/lib/admin-api"; import { Code, countMatches, DiffView, ErrorNote, ExtLink, fmtBytes, JsonView, KV, LevelBadge, Mono, Panel, RefreshButton, Spinner, Stat } from "./primitives"; import { AdminPageHeader } from "./shell"; import { useToast } from "./toast"; import { useAdminMutation, useAdminQuery } from "./use-admin-query"; const HTML_PREVIEW_CAP = 400_000; function isHtml(ct: string | null | undefined, body: string): boolean { return /html|xml/i.test(ct ?? "") || / n.remove()); 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"]); const out: string[] = []; const walk = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) out.push(node.textContent ?? ""); else if (node.nodeType === Node.ELEMENT_NODE) { const el = node as Element; const block = blocks.has(el.tagName); if (block) out.push("\n"); el.childNodes.forEach(walk); if (block) out.push("\n"); } }; walk(doc.body ?? doc); return out .join("") .split("\n") .map((l) => l.replace(/\s+/g, " ").trim()) .filter((l, i, arr) => l || (i > 0 && arr[i - 1])) .join("\n") .trim(); } catch { return html; } } /** Injects so relative assets resolve; scripts are stripped and the frame is sandboxed. */ export function srcDocFor(html: string, baseUrl: string | null): string { const clean = html.replace(/]*>[\s\S]*?<\/script>/gi, "").replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, ""); const base = baseUrl ? `` : ""; const csp = ``; if (/]*>/i.test(clean)) return clean.replace(/]*>/i, (m) => `${m}${csp}${base}`); return `${csp}${base}${clean}`; } interface RawBody { loading: boolean; text: string; contentType: string | null; truncated: boolean; error: string | null; } /** Raw body for (document, version) through the proxy. `loading` is derived from the result key (no setState in the effect body). */ function useRawBody(docId: string, version: string | null, enabled: boolean): RawBody { const key = `${docId}|${version ?? ""}`; const [slot, setSlot] = useState<{ key: string; text: string; contentType: string | null; truncated: boolean; error: string | null }>({ key: "", text: "", contentType: null, truncated: false, error: null }); useEffect(() => { if (!enabled) return; let alive = true; void adminFetchText(`documents/${encodeURIComponent(docId)}/raw`, version ? { version } : undefined).then((r) => { if (!alive) return; setSlot({ key, text: r.ok ? r.text : "", contentType: r.contentType, truncated: r.truncated, error: r.ok ? null : (r.error ?? "failed") }); }); return () => { alive = false; }; }, [docId, version, enabled, key]); const current = slot.key === key; return { loading: enabled && !current, text: current ? slot.text : "", contentType: current ? slot.contentType : null, truncated: current && slot.truncated, error: current ? slot.error : null }; } export function DocumentInspectView({ id }: { id: string }) { const q = useAdminQuery(`documents/${encodeURIComponent(id)}`, { timeoutMs: 30_000 }); const { busy, run } = useAdminMutation(); const toast = useToast(); const d = q.data?.document; const versions = useMemo(() => q.data?.versions ?? [], [q.data]); // A defaults to the newest version, B to the previous one, until the user picks const [pickA, setPickA] = useState(null); const [pickB, setPickB] = useState(null); const selA = pickA ?? versions[0]?.id ?? null; const selB = pickB ?? versions[1]?.id ?? null; const [diffMode, setDiffMode] = useState<"text" | "html">("text"); const rawA = useRawBody(id, selA, Boolean(selA && selB)); const rawB = useRawBody(id, selB, Boolean(selA && selB)); const newer = useMemo(() => versions.find((v) => v.id === selA) ?? null, [versions, selA]); const older = useMemo(() => versions.find((v) => v.id === selB) ?? null, [versions, selB]); const [aText, bText] = useMemo(() => { const proj = (raw: typeof rawA) => (raw.text ? (diffMode === "text" && isHtml(raw.contentType, raw.text) ? htmlToTextClient(raw.text) : raw.text) : ""); return [proj(rawA), proj(rawB)]; }, [rawA, rawB, diffMode]); // order the diff old → new regardless of which radio was picked const [oldText, newText, oldLabel, newLabel] = useMemo(() => { const ta = newer?.fetchedAt ? new Date(newer.fetchedAt).getTime() : 0; const tb = older?.fetchedAt ? new Date(older.fetchedAt).getTime() : 0; return ta >= tb ? [bText, aText, older?.fetchedAt ?? "", newer?.fetchedAt ?? ""] : [aText, bText, newer?.fetchedAt ?? "", older?.fetchedAt ?? ""]; }, [aText, bText, newer, older]); const changesVersion = useMemo(() => { if (!newer && !older) return null; const ta = newer?.fetchedAt ? new Date(newer.fetchedAt).getTime() : 0; const tb = older?.fetchedAt ? new Date(older.fetchedAt).getTime() : 0; return ta >= tb ? newer : older; }, [newer, older]); // raw viewer const [viewVersion, setViewVersion] = useState(null); const [viewTab, setViewTab] = useState("rendered"); const [search, setSearch] = useState(""); const raw = useRawBody(id, viewVersion, Boolean(d)); const rawIsHtml = isHtml(raw.contentType ?? d?.contentType, raw.text); const rawIsPdf = /pdf/i.test(raw.contentType ?? d?.contentType ?? ""); const rawIsJson = /json/i.test(raw.contentType ?? d?.contentType ?? ""); const textProjection = useMemo(() => (rawIsHtml ? htmlToTextClient(raw.text) : raw.text), [raw.text, rawIsHtml]); const srcDoc = useMemo(() => (rawIsHtml ? srcDocFor(raw.text.slice(0, 2_000_000), d?.canonicalUrl ?? d?.url ?? null) : ""), [raw.text, rawIsHtml, d]); const jsonValue = useMemo(() => { if (!rawIsJson) return null; try { return JSON.parse(raw.text) as unknown; } catch { return null; } }, [raw.text, rawIsJson]); const act = useCallback( async (action: "reprocess" | "refetch" | "quarantine", body?: unknown) => { const r = await run<{ enqueued?: boolean; job?: { id?: string }; quarantined?: boolean }>(action, `documents/${encodeURIComponent(id)}/${action}`, { body: body ?? {} }); if (r.ok) { if (action === "quarantine") { q.mutate((prev) => (prev ? { ...prev, document: { ...prev.document, quarantined: Boolean(r.data.quarantined) } } : prev)); toast({ tone: "ok", title: r.data.quarantined ? "Document quarantined" : "Quarantine cleared" }); } else toast({ tone: "ok", title: action === "reprocess" ? "Reprocess enqueued" : "Forced refetch enqueued", description: r.data.job?.id ? `job ${r.data.job.id}` : undefined }); } else toast({ tone: "danger", title: `${action} failed`, description: r.error }); }, [id, run, q, toast], ); if (q.error && !q.data) { return (
{id}} /> void q.refresh()} />
); } return (
Documents {d && ( <> / {d.connectorId} )} } title={d ? {d.url.replace(/^https?:\/\//, "")} : {id}} description={ d ? ( open {d.pageType} {d.statusCode != null && ( = 400 ? "danger" : "neutral"}> HTTP {d.statusCode} )} {d.quarantined && ( quarantined )} {d.extractOk === false && ( extraction failed )} {d.id} ) : undefined } aside={ d ? ( <> void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} /> ) : undefined } /> {d?.error && } {!d ? ( ) : (
} note={d.lastChanged ? changed : undefined} /> : d.quarantined ? "quarantined" : DASH} note={d.changeFrequencyScore != null ? `change freq ${d.changeFrequencyScore.toFixed(2)}` : undefined} />
)}
{!d ? ( ) : ( }, { k: "Title", v: d.title, title: d.title ?? undefined }, { k: "Classifier", v: d.classifier ? {d.classifier} : DASH }, { k: "Priority", v: fmtInt(d.priority) }, { k: "Content hash", v: d.contentHash ? {d.contentHash.slice(0, 16)}… : DASH }, { k: "ETag", v: d.etag ? {d.etag} : DASH }, { k: "Last-Modified", v: d.lastModified }, { k: "Storage key", v: d.storageKey ? {d.storageKey} : DASH }, { k: "Source", v: {d.sourceId} }, { k: "Discovered from", v: d.discoveredFrom ? {d.discoveredFrom} : DASH }, { k: "First seen", v: fmtDateTimeUtc(d.firstSeen) }, { k: "Last checked", v: fmtDateTimeUtc(d.lastChecked) }, { k: "Updated", v: fmtDateTimeUtc(d.updatedAt) }, ]} /> )} {!q.data ? (
) : versions.length === 0 ? ( ) : (
{versions.map((v) => ( ))}
A B Fetched Lvl HTTP Size Signif. Changes Hash
setPickA(v.id)} className="size-4 accent-[var(--accent)]" /> setPickB(v.id)} className="size-4 accent-[var(--ink)]" /> {fmtDateTimeUtc(v.fetchedAt)} {v.statusCode ?? DASH} {fmtBytes(v.sizeBytes)} = 60 && "text-accent")}> {v.significance} {fmtInt(v.detectedChanges?.length ?? 0)} {v.contentHash.slice(0, 10)}
)}
{selA && selB && selA !== selB && (
} > {rawA.loading || rawB.loading ? (
Loading both bodies from storage…
) : rawA.error || rawB.error ? ( ) : (
)} )} {raw.contentType}{raw.truncated ? " · truncated at 5 MB" : ""}{viewVersion ? ` · version ${viewVersion}` : " · current"} : undefined} aside={
{versions.length > 0 && ( )} download
} padded={false} > {raw.loading ? (
Loading body…
) : raw.error ? (
) : rawIsPdf ? (