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
No issues recorded.
;
return (
{issues.map((issue, i) => (
{issue.reasonCode ? {issue.reasonCode} : null}
{issue.reasonText ?? "—"}
{issue.rfcRefs?.length ? (
[{issue.rfcRefs.join(", ")}]
) : null}
))}
);
}
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 ;
if (validationQuery.isError) {
return (
validationQuery.refetch()}
title="Failed to load validation summary"
/>
);
}
const validation = validationQuery.data;
const explain = explainMutation.data;
return (
Final status
Audit result
Detail
{validation.detailSummary ?? "—"}
File validation (parsevalidate)
Chain validation (chainvalidate)
Explain validation
{explainMutation.isError ? (
Explain failed: {explainMutation.error.message}
) : null}
{explain ? (
Explain mode: {explain.explainMode ?? "audit projection"} —{" "}
{explain.authoritative
? "authoritative revalidation."
: "not a full revalidation; derived from the run audit trail."}
Final status
Parse stage
Chain stage
{" "}
{formatInt(explain.chainvalidate?.edgesCount)} chain edges
{explain.chainvalidate?.note ? (
<>
Note
{explain.chainvalidate.note}
>
) : null}
) : null}
);
}
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 ;
if (chainQuery.isError) {
return chainQuery.refetch()} title="Failed to load chain" />;
}
const edges = chainQuery.data;
if (edges.length === 0) {
return ;
}
const columns: Column<(typeof edges)[number]>[] = [
{ key: "relation", header: "Relation", render: (edge) => {edge.relation} },
{
key: "from",
header: "From URI",
render: (edge) => ,
},
{
key: "to",
header: "To URI",
render: (edge) => ,
},
{ key: "status", header: "Status", render: (edge) => },
];
return `${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 (
objectQuery.refetch()} title="Failed to load object" />
);
}
if (objectQuery.isPending) {
return (
);
}
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 (
Objects
/
{objectTypeLabel(object.objectType)}
{objectTypeLabel(object.objectType)} object
}
subtitle={ }
actions={
<>
rawMutation.mutate()}
disabled={rawMutation.isPending}
>
{rawMutation.isPending ? : }
Download raw
exportMutation.mutate()}
disabled={exportRunning}
>
{exportMutation.isPending ? : }
Export object set
>
}
/>
{rawMutation.isError ? (
Raw download failed: {rawMutation.error.message}. The query service needs
--repo-bytes-db to serve raw bytes.
) : null}
{exportMutation.isError ? (
Export failed to start: {exportMutation.error.message}
) : null}
{exportJobQuery.data ? : null}
{exportJobQuery.data?.status === "complete" ? (
Export complete — {formatInt(exportJobQuery.data.objectCount)} objects. Find it any
time on the Exports page.
) : null}
Object type
{objectTypeLabel(object.objectType)}
URI
SHA-256
Instance ID
Result
Reject reason
{object.rejectReason ?? — }
Detail
{object.detailSummary ?? — }
Repository
{object.repoId}
Publication point
{object.ppId}
{projectionQuery.isPending ? (
) : projectionQuery.isError ? (
projectionQuery.refetch()}
title="Failed to load parsed projection"
/>
) : (
)}
{eeProjection ? (
) : tab === "ee" ? (
This object has no embedded EE certificate in its CMS wrapper.
) : null}
);
}