405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
import { useState } from "react";
|
|
import { Link, useParams, useSearchParams } from "react-router-dom";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { Download, PackageOpen, Play, RefreshCw } from "lucide-react";
|
|
import { apiFetchBlob, saveBlob } from "../api/client";
|
|
import {
|
|
explainObjectValidation,
|
|
getObject,
|
|
getObjectChain,
|
|
getObjectProjection,
|
|
getObjectValidation,
|
|
rawObjectUrl,
|
|
} from "../api/service";
|
|
import type { ValidationIssue } from "../api/schemas";
|
|
import { CopyableValue } from "../components/CopyableValue";
|
|
import { DataTable, type Column } from "../components/DataTable";
|
|
import { PageHeader } from "../components/PageHeader";
|
|
import { Panel } from "../components/Panel";
|
|
import { CertificateView, ProjectionView } from "../components/ProjectionView";
|
|
import { StatusPill } from "../components/StatusPill";
|
|
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
|
import { TabPanel, Tabs } from "../components/Tabs";
|
|
import { WorkflowStatus } from "../components/WorkflowStatus";
|
|
import { formatInt, objectTypeLabel } from "../lib/format";
|
|
import { eeCertificateProjection } from "../lib/projection";
|
|
import { withRunParam } from "../lib/run";
|
|
import { useExportJob } from "../lib/useExportJob";
|
|
import { useRun } from "../lib/useRun";
|
|
|
|
function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string {
|
|
const last = uri?.split("/").filter(Boolean).pop();
|
|
if (last && last.length <= 128) return last;
|
|
const ext = type === "mft" ? ".mft" : type === "crl" ? ".crl" : type === "roa" ? ".roa" : type === "asa" ? ".asa" : ".cer";
|
|
return `${(sha256 ?? "object").slice(0, 16)}${ext}`;
|
|
}
|
|
|
|
function IssueList({ issues }: { issues: ValidationIssue[] }) {
|
|
if (issues.length === 0) return <p className="text-muted">No issues recorded.</p>;
|
|
return (
|
|
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
{issues.map((issue, i) => (
|
|
<li key={i}>
|
|
<StatusPill status={issue.severity} />
|
|
{issue.reasonCode ? <code style={{ marginLeft: 8 }}>{issue.reasonCode}</code> : null}
|
|
<span style={{ marginLeft: 8 }}>{issue.reasonText ?? "—"}</span>
|
|
{issue.rfcRefs?.length ? (
|
|
<span className="text-faint text-small" style={{ marginLeft: 8 }}>
|
|
[{issue.rfcRefs.join(", ")}]
|
|
</span>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function ValidationTab({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
|
const [forceRefresh, setForceRefresh] = useState(false);
|
|
const validationQuery = useQuery({
|
|
queryKey: ["object-validation", runId, objectInstanceId],
|
|
queryFn: () => getObjectValidation(runId, objectInstanceId),
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
const explainMutation = useMutation({
|
|
mutationFn: () => explainObjectValidation(runId, objectInstanceId, { forceRefresh }),
|
|
});
|
|
|
|
if (validationQuery.isPending) return <LoadingBlock label="Loading validation summary…" />;
|
|
if (validationQuery.isError) {
|
|
return (
|
|
<ErrorBlock
|
|
error={validationQuery.error}
|
|
onRetry={() => validationQuery.refetch()}
|
|
title="Failed to load validation summary"
|
|
/>
|
|
);
|
|
}
|
|
|
|
const validation = validationQuery.data;
|
|
const explain = explainMutation.data;
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
|
|
<dl className="meta-grid">
|
|
<dt>Final status</dt>
|
|
<dd><StatusPill status={validation.finalStatus} /></dd>
|
|
<dt>Audit result</dt>
|
|
<dd><StatusPill status={validation.auditResult} /></dd>
|
|
<dt>Detail</dt>
|
|
<dd>{validation.detailSummary ?? "—"}</dd>
|
|
</dl>
|
|
|
|
<section>
|
|
<h3 className="text-small" style={{ marginBottom: 8 }}>File validation (parsevalidate)</h3>
|
|
<p style={{ marginBottom: 8 }}>
|
|
<StatusPill status={validation.parsevalidate?.status} />
|
|
</p>
|
|
<IssueList issues={validation.parsevalidate?.issues ?? []} />
|
|
</section>
|
|
|
|
<section>
|
|
<h3 className="text-small" style={{ marginBottom: 8 }}>Chain validation (chainvalidate)</h3>
|
|
<p style={{ marginBottom: 8 }}>
|
|
<StatusPill status={validation.chainvalidate?.status} />
|
|
</p>
|
|
<IssueList issues={validation.chainvalidate?.issues ?? []} />
|
|
</section>
|
|
|
|
<section>
|
|
<h3 className="text-small" style={{ marginBottom: 8 }}>Explain validation</h3>
|
|
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap", marginBottom: 8 }}>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
onClick={() => explainMutation.mutate()}
|
|
disabled={explainMutation.isPending}
|
|
>
|
|
{explainMutation.isPending ? (
|
|
<span className="spinner small" aria-hidden="true" />
|
|
) : (
|
|
<Play size={13} aria-hidden="true" />
|
|
)}
|
|
{explain ? "Run again" : "Run explain"}
|
|
</button>
|
|
<label className="filter-check" style={{ paddingBottom: 0 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={forceRefresh}
|
|
onChange={(e) => setForceRefresh(e.target.checked)}
|
|
/>
|
|
Force refresh (bypass cached explain)
|
|
</label>
|
|
</div>
|
|
{explainMutation.isError ? (
|
|
<Notice kind="error">Explain failed: {explainMutation.error.message}</Notice>
|
|
) : null}
|
|
{explain ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
<Notice kind="info">
|
|
Explain mode: <strong>{explain.explainMode ?? "audit projection"}</strong> —{" "}
|
|
{explain.authoritative
|
|
? "authoritative revalidation."
|
|
: "not a full revalidation; derived from the run audit trail."}
|
|
</Notice>
|
|
<dl className="meta-grid">
|
|
<dt>Final status</dt>
|
|
<dd><StatusPill status={explain.finalStatus} /></dd>
|
|
<dt>Parse stage</dt>
|
|
<dd><StatusPill status={explain.parsevalidate?.status} /></dd>
|
|
<dt>Chain stage</dt>
|
|
<dd>
|
|
<StatusPill status={explain.chainvalidate?.status} />{" "}
|
|
<span className="text-muted text-small">
|
|
{formatInt(explain.chainvalidate?.edgesCount)} chain edges
|
|
</span>
|
|
</dd>
|
|
{explain.chainvalidate?.note ? (
|
|
<>
|
|
<dt>Note</dt>
|
|
<dd>{explain.chainvalidate.note}</dd>
|
|
</>
|
|
) : null}
|
|
</dl>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChainTab({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
|
const chainQuery = useQuery({
|
|
queryKey: ["object-chain", runId, objectInstanceId],
|
|
queryFn: () => getObjectChain(runId, objectInstanceId),
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
if (chainQuery.isPending) return <LoadingBlock label="Loading chain edges…" />;
|
|
if (chainQuery.isError) {
|
|
return <ErrorBlock error={chainQuery.error} onRetry={() => chainQuery.refetch()} title="Failed to load chain" />;
|
|
}
|
|
const edges = chainQuery.data;
|
|
if (edges.length === 0) {
|
|
return <EmptyBlock title="No chain edges" hint="No issuer/manifest relationships were recorded for this object." />;
|
|
}
|
|
|
|
const columns: Column<(typeof edges)[number]>[] = [
|
|
{ key: "relation", header: "Relation", render: (edge) => <span className="chip">{edge.relation}</span> },
|
|
{
|
|
key: "from",
|
|
header: "From URI",
|
|
render: (edge) => <CopyableValue value={edge.fromUri} max={48} label="from URI" />,
|
|
},
|
|
{
|
|
key: "to",
|
|
header: "To URI",
|
|
render: (edge) => <CopyableValue value={edge.toUri} max={48} label="to URI" />,
|
|
},
|
|
{ key: "status", header: "Status", render: (edge) => <StatusPill status={edge.status} /> },
|
|
];
|
|
|
|
return <DataTable columns={columns} rows={edges} rowKey={(e) => `${e.relation}:${e.fromUri}:${e.toUri}`} caption="Certificate chain edges" />;
|
|
}
|
|
|
|
export default function ObjectDetailPage() {
|
|
const { objectInstanceId = "" } = useParams();
|
|
const { runId } = useRun();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const tab = searchParams.get("tab") ?? "parsed";
|
|
|
|
const objectQuery = useQuery({
|
|
queryKey: ["object", runId, objectInstanceId],
|
|
queryFn: () => getObject(runId, objectInstanceId),
|
|
});
|
|
|
|
// Always fetch the parsed projection: the Parsed tab needs it, and the
|
|
// conditional "EE Parsed" tab derives from it (CMS-signed objects only).
|
|
const projectionQuery = useQuery({
|
|
queryKey: ["object-parsed", runId, objectInstanceId],
|
|
queryFn: () => getObjectProjection(runId, objectInstanceId),
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
const rawMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const blob = await apiFetchBlob(rawObjectUrl(runId, objectInstanceId));
|
|
const object = objectQuery.data;
|
|
saveBlob(blob, filenameFor(object?.uri, object?.sha256, object?.objectType ?? ""));
|
|
},
|
|
});
|
|
|
|
const {
|
|
startMutation: exportMutation,
|
|
jobQuery: exportJobQuery,
|
|
running: exportRunning,
|
|
} = useExportJob(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] });
|
|
|
|
if (objectQuery.isError) {
|
|
return (
|
|
<div className="page">
|
|
<PageHeader title="Object" />
|
|
<ErrorBlock error={objectQuery.error} onRetry={() => objectQuery.refetch()} title="Failed to load object" />
|
|
</div>
|
|
);
|
|
}
|
|
if (objectQuery.isPending) {
|
|
return (
|
|
<div className="page">
|
|
<PageHeader title="Object" />
|
|
<LoadingBlock />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const object = objectQuery.data;
|
|
const eeProjection = eeCertificateProjection(projectionQuery.data ?? null);
|
|
|
|
const setTab = (next: string) => {
|
|
const params = new URLSearchParams(searchParams);
|
|
params.set("tab", next);
|
|
setSearchParams(params, { preventScrollReset: true });
|
|
};
|
|
|
|
return (
|
|
<div className="page">
|
|
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
|
<Link to={withRunParam("/objects", runId)}>Objects</Link>
|
|
<span aria-hidden="true">/</span>
|
|
<span>{objectTypeLabel(object.objectType)}</span>
|
|
</nav>
|
|
|
|
<PageHeader
|
|
title={
|
|
<span style={{ display: "inline-flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
|
{objectTypeLabel(object.objectType)} object
|
|
<StatusPill status={object.rejected ? "rejected" : object.result} />
|
|
<StatusPill status={object.sourceSection} />
|
|
</span>
|
|
}
|
|
subtitle={<CopyableValue value={object.uri} max={110} label="object URI" />}
|
|
actions={
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
onClick={() => rawMutation.mutate()}
|
|
disabled={rawMutation.isPending}
|
|
>
|
|
{rawMutation.isPending ? <span className="spinner small" aria-hidden="true" /> : <Download size={13} aria-hidden="true" />}
|
|
Download raw
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
onClick={() => exportMutation.mutate()}
|
|
disabled={exportRunning}
|
|
>
|
|
{exportMutation.isPending ? <RefreshCw size={13} aria-hidden="true" /> : <PackageOpen size={13} aria-hidden="true" />}
|
|
Export object set
|
|
</button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{rawMutation.isError ? (
|
|
<Notice kind="error">
|
|
Raw download failed: {rawMutation.error.message}. The query service needs
|
|
--repo-bytes-db to serve raw bytes.
|
|
</Notice>
|
|
) : null}
|
|
{exportMutation.isError ? (
|
|
<Notice kind="error">Export failed to start: {exportMutation.error.message}</Notice>
|
|
) : null}
|
|
{exportJobQuery.data ? <WorkflowStatus job={exportJobQuery.data} runId={runId} /> : null}
|
|
{exportJobQuery.data?.status === "complete" ? (
|
|
<Notice kind="info">
|
|
Export complete — {formatInt(exportJobQuery.data.objectCount)} objects. Find it any
|
|
time on the <Link to={withRunParam("/exports", runId)}>Exports page</Link>.
|
|
</Notice>
|
|
) : null}
|
|
|
|
<Panel className="object-header-card">
|
|
<dl className="meta-grid">
|
|
<dt>Object type</dt>
|
|
<dd><span className="chip">{objectTypeLabel(object.objectType)}</span></dd>
|
|
<dt>URI</dt>
|
|
<dd><CopyableValue value={object.uri} max={110} label="object URI" /></dd>
|
|
<dt>SHA-256</dt>
|
|
<dd><CopyableValue value={object.sha256} max={72} label="object sha256" /></dd>
|
|
<dt>Instance ID</dt>
|
|
<dd><CopyableValue value={object.objectInstanceId} max={56} label="object instance id" /></dd>
|
|
<dt>Result</dt>
|
|
<dd><StatusPill status={object.rejected ? "rejected" : object.result} /></dd>
|
|
<dt>Reject reason</dt>
|
|
<dd>{object.rejectReason ?? <span className="text-faint">—</span>}</dd>
|
|
<dt>Detail</dt>
|
|
<dd>{object.detailSummary ?? <span className="text-faint">—</span>}</dd>
|
|
<dt>Repository</dt>
|
|
<dd>
|
|
<Link className="mono" to={withRunParam(`/repositories/${encodeURIComponent(object.repoId)}`, runId)}>
|
|
{object.repoId}
|
|
</Link>
|
|
</dd>
|
|
<dt>Publication point</dt>
|
|
<dd>
|
|
<Link className="mono" to={withRunParam(`/publication-points/${encodeURIComponent(object.ppId)}`, runId)}>
|
|
{object.ppId}
|
|
</Link>
|
|
</dd>
|
|
</dl>
|
|
</Panel>
|
|
|
|
<Panel flush>
|
|
<Tabs
|
|
tabs={[
|
|
{ id: "parsed", label: "Parsed" },
|
|
...(eeProjection ? [{ id: "ee", label: "EE Parsed" }] : []),
|
|
{ id: "validation", label: "Validation" },
|
|
{ id: "chain", label: "Chain" },
|
|
]}
|
|
active={tab}
|
|
onChange={setTab}
|
|
ariaLabel="Object detail sections"
|
|
/>
|
|
<TabPanel id="parsed" active={tab}>
|
|
<div style={{ padding: 16 }}>
|
|
{projectionQuery.isPending ? (
|
|
<LoadingBlock label="Loading parsed projection…" />
|
|
) : projectionQuery.isError ? (
|
|
<ErrorBlock
|
|
error={projectionQuery.error}
|
|
onRetry={() => projectionQuery.refetch()}
|
|
title="Failed to load parsed projection"
|
|
/>
|
|
) : (
|
|
<ProjectionView
|
|
record={projectionQuery.data ?? null}
|
|
runId={runId}
|
|
objectInstanceId={objectInstanceId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</TabPanel>
|
|
{eeProjection ? (
|
|
<TabPanel id="ee" active={tab}>
|
|
<div style={{ padding: 16 }}>
|
|
<CertificateView projection={eeProjection} />
|
|
</div>
|
|
</TabPanel>
|
|
) : tab === "ee" ? (
|
|
<div style={{ padding: 16 }}>
|
|
<Notice kind="info">This object has no embedded EE certificate in its CMS wrapper.</Notice>
|
|
</div>
|
|
) : null}
|
|
<TabPanel id="validation" active={tab}>
|
|
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
|
|
</TabPanel>
|
|
<TabPanel id="chain" active={tab}>
|
|
<ChainTab runId={runId} objectInstanceId={objectInstanceId} />
|
|
</TabPanel>
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|