rpki/ui/rpki-explorer/src/pages/ObjectDetailPage.tsx

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>
);
}