20260718 重构 RPKI Explorer 查询界面

This commit is contained in:
2026-07-18 10:38:38 +08:00
parent a77982deaa
commit a5902c0a5e
79 changed files with 7964 additions and 4861 deletions

View File

@@ -0,0 +1,111 @@
import { useQuery } from "@tanstack/react-query";
import { getHealth, getServiceInfo } from "../api/service";
import { CopyableValue } from "../components/CopyableValue";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { ErrorBlock } from "../components/StateBlock";
import { useRun } from "../lib/useRun";
const ENDPOINTS: { method: "GET" | "POST"; path: string; note: string }[] = [
{ method: "GET", path: "/api/v1/healthz", note: "Service health + latest indexed run" },
{ method: "GET", path: "/api/v1/runs", note: "Run history (cursor paged)" },
{ method: "GET", path: "/api/v1/runs/{run}", note: "Run record ('latest' accepted)" },
{ method: "GET", path: "/api/v1/runs/{run}/repos", note: "Repositories (cursor paged)" },
{ method: "GET", path: "/api/v1/runs/{run}/repos/{id}/publication-points", note: "PPs of a repository" },
{ method: "GET", path: "/api/v1/runs/{run}/publication-points/{id}/objects", note: "Objects of a PP (filters: type,result,rejected,reason,q)" },
{ method: "GET", path: "/api/v1/runs/{run}/objects", note: "All objects (streamed; same filters)" },
{ method: "GET", path: "/api/v1/runs/{run}/objects/by-uri?uri=", note: "Exact URI lookup" },
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/parsed", note: "Parsed projection (needs repo-bytes-db)" },
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/validation", note: "Validation summary" },
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/chain", note: "Chain edges" },
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/raw", note: "Raw DER bytes (needs repo-bytes-db)" },
{ method: "GET", path: "/api/v1/runs/{run}/issues", note: "Rejected/errored objects (filters: reason,type,repoId,ppId)" },
{ method: "GET", path: "/api/v1/runs/{run}/search?q=", note: "Unified search: URI / sha256 / repo host / PP URI" },
{ method: "GET", path: "/api/v1/runs/{run}/stats/{validation,object-types,reasons}", note: "Aggregated counters" },
{ method: "POST", path: "/api/v1/runs/{run}/exports", note: "Start export job (repo / publication_point / object_set)" },
{ method: "GET", path: "/api/v1/runs/{run}/exports", note: "Export job list (process memory)" },
{ method: "GET", path: "/api/v1/runs/{run}/exports/{job}/download", note: "Download export tarball" },
{ method: "POST", path: "/api/v1/runs/{run}/objects/{id}/validation/explain", note: "On-demand validation explain (audit projection)" },
];
export default function ApiStatusPage() {
const { runId, runQuery } = useRun();
const infoQuery = useQuery({ queryKey: ["service-info"], queryFn: getServiceInfo });
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
return (
<div className="page">
<PageHeader title="API" subtitle="Query service connection status and endpoint reference" />
<div className="detail-grid">
<Panel title="Service">
{infoQuery.isError ? (
<ErrorBlock error={infoQuery.error} onRetry={() => infoQuery.refetch()} title="Query service unreachable" />
) : (
<dl className="meta-grid">
<dt>Service</dt>
<dd className="mono">{infoQuery.data?.service ?? "…"}</dd>
<dt>API version</dt>
<dd className="mono">{infoQuery.data?.version ?? "…"}</dd>
<dt>Health</dt>
<dd>
{healthQuery.isError ? (
<StatusPill status="error" label="unhealthy" />
) : (
<StatusPill status={healthQuery.data?.status ?? "unknown"} />
)}
</dd>
<dt>Latest indexed run</dt>
<dd className="mono">{healthQuery.data?.latestReadyRun ?? "—"}</dd>
</dl>
)}
</Panel>
<Panel title="Explorer context">
<dl className="meta-grid">
<dt>Active run</dt>
<dd className="mono">{runQuery.data?.runId ?? runId}</dd>
<dt>Index status</dt>
<dd><StatusPill status={runQuery.data?.indexStatus} /></dd>
<dt>API base</dt>
<dd><CopyableValue value={`${window.location.origin}/api/v1`} max={64} label="API base URL" /></dd>
</dl>
</Panel>
</div>
<Panel title="Deployment notes">
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
<li>The query service has no CORS headers and no authentication — always front it with a same-origin proxy (this SPA) or an API gateway.</li>
<li><code>/parsed</code>, <code>/raw</code> and exports require <code>--repo-bytes-db</code>.</li>
<li>All list endpoints paginate with <code>limit</code> + <code>cursor</code>; the envelope is <code>{"{data, page, meta}"}</code>.</li>
<li>VRP IP/prefix/ASN lookup is tracked as backend feature #070 and is not available yet.</li>
</ul>
</Panel>
<Panel title="Endpoint reference" flush>
<div className="table-wrap">
<table className="data-table endpoint-table">
<thead>
<tr>
<th scope="col">Endpoint</th>
<th scope="col">Purpose</th>
</tr>
</thead>
<tbody>
{ENDPOINTS.map((endpoint) => (
<tr key={endpoint.path}>
<td>
<span className={`method${endpoint.method === "POST" ? " post" : ""}`}>{endpoint.method}</span>
{endpoint.path}
</td>
<td>{endpoint.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useQuery } from "@tanstack/react-query";
import { exportDownloadUrl, listExports } from "../api/service";
import type { ExportJobRecord } from "../api/schemas";
import { DataTable, type Column } from "../components/DataTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { Notice } from "../components/StateBlock";
import { formatBytes, formatInt, formatRelative, formatUtc } from "../lib/format";
import { useRun } from "../lib/useRun";
/**
* Export job history for the active run. Jobs are held in the query service
* process memory, so the list resets when the service restarts.
*/
export default function ExportsPage() {
const { runId } = useRun();
const exportsQuery = useQuery({
queryKey: ["exports", runId],
queryFn: () => listExports(runId, { limit: 50 }),
refetchInterval: (query) => {
const jobs = query.state.data?.items ?? [];
return jobs.some((job) => job.status === "running") ? 2000 : false;
},
});
const columns: Column<ExportJobRecord>[] = [
{
key: "job",
header: "Job",
render: (job) => <span className="mono text-small">{job.jobId.slice(0, 12)}…</span>,
},
{ key: "scope", header: "Scope", render: (job) => <span className="chip">{job.scope}</span> },
{
key: "target",
header: "Target",
render: (job) => (
<span className="mono text-small">{job.repoId ?? job.ppId ?? "object set"}</span>
),
},
{ key: "status", header: "Status", render: (job) => <StatusPill status={job.status} /> },
{
key: "objects",
header: "Objects",
numeric: true,
render: (job) => formatInt(job.objectCount),
},
{ key: "bytes", header: "Size", numeric: true, render: (job) => formatBytes(job.bytesWritten) },
{
key: "created",
header: "Created",
render: (job) => <span title={formatUtc(job.createdAt)}>{formatRelative(job.createdAt)}</span>,
},
{
key: "error",
header: "Error",
render: (job) =>
job.error ? <span className="text-small" title={job.error}>{job.error}</span> : <span className="text-faint">—</span>,
},
{
key: "download",
header: "",
render: (job) =>
job.status === "complete" ? (
<a className="btn small primary" href={exportDownloadUrl(runId, job.jobId)} download>
Download
</a>
) : null,
},
];
return (
<div className="page">
<PageHeader
title="Exports"
subtitle="Evidence bundles (raw object tarballs) produced by the query service"
/>
<Notice kind="info">
Export jobs live in the query service process memory and are cleared on
restart. Creating exports requires the service to run with --repo-bytes-db.
New exports can be started from an object, publication point or repository
page.
</Notice>
<Panel title="Jobs" flush>
<DataTable
columns={columns}
rows={exportsQuery.data?.items ?? []}
rowKey={(job) => job.jobId}
loading={exportsQuery.isPending}
error={exportsQuery.isError ? exportsQuery.error : undefined}
onRetry={() => exportsQuery.refetch()}
emptyTitle="No export jobs yet"
emptyHint="Start an export from an object detail page (object set) to see it here."
caption="Export jobs"
/>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Link } from "react-router-dom";
export default function NotFoundPage() {
return (
<div className="page">
<div className="not-found">
<div className="not-found-code">404</div>
<p className="text-muted">This page does not exist.</p>
<p style={{ marginTop: 12 }}>
<Link className="btn" to="/">
Back to Overview
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,393 @@
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 {
createExport,
explainObjectValidation,
getExportJob,
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 { 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 { formatBytes, formatInt, objectTypeLabel } from "../lib/format";
import { withRunParam } from "../lib/run";
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 [exportJobId, setExportJobId] = useState<string | null>(null);
const objectQuery = useQuery({
queryKey: ["object", runId, objectInstanceId],
queryFn: () => getObject(runId, objectInstanceId),
});
const projectionQuery = useQuery({
queryKey: ["object-parsed", runId, objectInstanceId],
queryFn: () => getObjectProjection(runId, objectInstanceId),
enabled: tab === "parsed",
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 exportMutation = useMutation({
mutationFn: () => createExport(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] }),
onSuccess: (job) => setExportJobId(job.jobId),
});
const exportJobQuery = useQuery({
queryKey: ["export-job", runId, exportJobId],
queryFn: () => getExportJob(runId, exportJobId!),
enabled: exportJobId !== null,
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
});
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 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={exportMutation.isPending || exportJobQuery.data?.status === "running"}
>
{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}
<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>Size</dt>
<dd>{formatBytes(object.sizeBytes)}</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" },
{ 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>
<TabPanel id="validation" active={tab}>
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
</TabPanel>
<TabPanel id="chain" active={tab}>
<ChainTab runId={runId} objectInstanceId={objectInstanceId} />
</TabPanel>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { listObjects } from "../api/service";
import { Notice } from "../components/StateBlock";
import {
EMPTY_OBJECT_FILTERS,
ObjectsTable,
type ObjectFilterValues,
} from "../components/ObjectsTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { useRun } from "../lib/useRun";
function filtersFromParams(params: URLSearchParams): ObjectFilterValues {
return {
type: params.get("type") ?? "",
result: params.get("result") ?? "",
rejectedOnly: params.get("rejected") === "true",
q: params.get("q") ?? "",
};
}
export default function ObjectsPage() {
const { runId } = useRun();
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo(() => filtersFromParams(searchParams), [searchParams]);
const setFilters = (next: ObjectFilterValues) => {
const params = new URLSearchParams(searchParams);
const write = (key: string, value: string) => {
if (value) params.set(key, value);
else params.delete(key);
};
write("type", next.type);
write("result", next.result);
write("q", next.q);
if (next.rejectedOnly) params.set("rejected", "true");
else params.delete("rejected");
setSearchParams(params, { preventScrollReset: true });
};
const hasFilters =
filters.type !== EMPTY_OBJECT_FILTERS.type ||
filters.result !== "" ||
filters.rejectedOnly ||
filters.q !== "";
return (
<div className="page">
<PageHeader
title="Objects"
subtitle="Browse every object instance indexed for the active run. Lists stream from the run report — deep paging on large runs can be slow."
/>
{!hasFilters ? (
<Notice kind="info">
This run may contain hundreds of thousands of objects. Use the filters to
narrow the scan; all filters are applied server-side.
</Notice>
) : null}
<Panel title="All objects" flush>
<ObjectsTable
runId={runId}
queryKeyScope="global"
fetcher={(f) => listObjects(runId, f)}
filters={filters}
onFiltersChange={setFilters}
/>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,381 @@
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQueries } from "@tanstack/react-query";
import {
Cell,
Pie,
PieChart,
ResponsiveContainer,
Bar,
BarChart,
XAxis,
YAxis,
Tooltip,
} from "recharts";
import { Clock, GitBranch, Timer } from "lucide-react";
import {
getStatsObjectTypes,
getStatsReasons,
getStatsValidation,
listRepos,
} from "../api/service";
import type { RepositoryRecord } from "../api/schemas";
import { DataTable, type Column } from "../components/DataTable";
import { KpiCard } from "../components/KpiCard";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
import {
formatDurationMs,
formatInt,
formatPercent,
formatRelative,
formatUtc,
objectTypeLabel,
truncateMiddle,
} from "../lib/format";
import { withRunParam } from "../lib/run";
import { useRun } from "../lib/useRun";
/** Semantic colors keyed by validation result — never assigned by index. */
const RESULT_COLORS: Record<string, string> = {
ok: "#16a34a",
valid: "#16a34a",
warnings: "#d97706",
warning: "#d97706",
error: "#dc2626",
invalid: "#dc2626",
rejected: "#dc2626",
skipped: "#94a3b8",
unknown: "#94a3b8",
};
const FALLBACK_COLORS = ["#2563eb", "#0ea5e9", "#6366f1", "#8b5cf6", "#64748b"];
const TYPE_PALETTE = ["#1d4ed8", "#2563eb", "#3b82f6", "#60a5fa", "#93c5fd", "#0ea5e9", "#38bdf8", "#7dd3fc"];
function colorForResult(name: string, index: number): string {
return RESULT_COLORS[name.toLowerCase()] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
}
export default function OverviewPage() {
const { runId, runQuery } = useRun();
const navigate = useNavigate();
const [validationQuery, typesQuery, reasonsQuery, reposQuery] = useQueries({
queries: [
{
queryKey: ["stats", runId, "validation"],
queryFn: () => getStatsValidation(runId),
staleTime: 60_000,
},
{
queryKey: ["stats", runId, "object-types"],
queryFn: () => getStatsObjectTypes(runId),
staleTime: 60_000,
},
{
queryKey: ["stats", runId, "reasons"],
queryFn: () => getStatsReasons(runId),
staleTime: 60_000,
},
{
queryKey: ["repos", runId, "top"],
queryFn: () => listRepos(runId, { limit: 8 }),
staleTime: 60_000,
},
],
});
const run = runQuery.data;
const counts = run?.counts;
const validationData = useMemo(() => {
const map = validationQuery.data ?? {};
return Object.entries(map)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}, [validationQuery.data]);
const validationTotal = useMemo(
() => validationData.reduce((sum, entry) => sum + entry.value, 0),
[validationData],
);
const typeData = useMemo(() => {
const map = typesQuery.data ?? {};
return Object.entries(map)
.map(([name, value]) => ({ name: objectTypeLabel(name), value }))
.sort((a, b) => b.value - a.value)
.slice(0, 8);
}, [typesQuery.data]);
const reasonRows = useMemo(() => {
const map = reasonsQuery.data ?? {};
return Object.entries(map)
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
}, [reasonsQuery.data]);
const maxReason = reasonRows[0]?.count ?? 0;
if (runQuery.isError) {
return (
<div className="page">
<PageHeader title="Overview" />
<ErrorBlock
error={runQuery.error}
onRetry={() => runQuery.refetch()}
title="Failed to load the latest run"
/>
</div>
);
}
const repoColumns: Column<RepositoryRecord>[] = [
{
key: "host",
header: "Repository",
render: (repo) => (
<div>
<div className="cell-main">{repo.host}</div>
<div className="text-faint text-small mono ellipsis" style={{ maxWidth: 260 }}>
{truncateMiddle(repo.uri, 56)}
</div>
</div>
),
},
{
key: "transport",
header: "Transport",
render: (repo) => <span className="chip">{repo.transport ?? "unknown"}</span>,
},
{ key: "objects", header: "Objects", numeric: true, render: (repo) => formatInt(repo.objects) },
{
key: "rejected",
header: "Rejected",
numeric: true,
render: (repo) =>
repo.rejectedObjects ? (
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>
{formatInt(repo.rejectedObjects)}
</span>
) : (
"0"
),
},
{
key: "duration",
header: "Sync time",
numeric: true,
render: (repo) => formatDurationMs(repo.syncDurationMsTotal),
},
];
return (
<div className="page">
<PageHeader
title="Overview"
subtitle={
run ? (
<span className="overview-run-strip">
<span className="run-strip-item mono">{run.runId}</span>
<span className="run-strip-item">
<Clock size={13} aria-hidden="true" />
<span title={formatUtc(run.validationTime)}>
validated {formatRelative(run.validationTime)}
</span>
</span>
{run.syncMode ? (
<span className="run-strip-item">
<GitBranch size={13} aria-hidden="true" /> {run.syncMode} sync
</span>
) : null}
<span className="run-strip-item">
<Timer size={13} aria-hidden="true" /> wall {formatDurationMs(run.wallMs)}
</span>
{run.indexStatus ? <StatusPill status={run.indexStatus} label={`index ${run.indexStatus}`} /> : null}
</span>
) : undefined
}
/>
{runQuery.isPending ? (
<LoadingBlock label="Loading latest run…" />
) : (
<>
<div className="kpi-grid">
<KpiCard label="VRPs" value={formatInt(counts?.vrps)} to={withRunParam("/objects?type=roa", runId)} />
<KpiCard label="ASPAs" value={formatInt(counts?.aspas)} to={withRunParam("/objects?type=aspa", runId)} />
<KpiCard label="Objects" value={formatInt(counts?.objects)} to={withRunParam("/objects", runId)} />
<KpiCard
label="Publication points"
value={formatInt(counts?.publicationPoints)}
to={withRunParam("/publication-points", runId)}
/>
<KpiCard
label="Rejected"
value={formatInt(counts?.rejectedObjects)}
tone="red"
to={withRunParam("/validation", runId)}
/>
<KpiCard
label="Warnings"
value={formatInt(counts?.warnings)}
tone="amber"
sub="objects with warnings"
/>
</div>
<div className="overview-charts">
<Panel title="Validation results" subtitle="Object audit results for this run">
{validationQuery.isError ? (
<ErrorBlock error={validationQuery.error} onRetry={() => validationQuery.refetch()} />
) : validationQuery.isPending ? (
<LoadingBlock />
) : validationData.length === 0 ? (
<p className="text-muted">No validation stats recorded.</p>
) : (
<>
<div className="chart-box">
<ResponsiveContainer>
<PieChart>
<Pie
data={validationData}
dataKey="value"
nameKey="name"
innerRadius="58%"
outerRadius="88%"
paddingAngle={2}
strokeWidth={0}
>
{validationData.map((entry, index) => (
<Cell key={entry.name} fill={colorForResult(entry.name, index)} />
))}
</Pie>
<Tooltip
formatter={(value, name) => [
`${formatInt(Number(value))} (${formatPercent(Number(value), validationTotal)})`,
String(name),
]}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="chart-legend">
{validationData.map((entry, index) => (
<span className="legend-item" key={entry.name}>
<span
className="legend-swatch"
style={{ background: colorForResult(entry.name, index) }}
/>
{entry.name} · {formatInt(entry.value)} ({formatPercent(entry.value, validationTotal)})
</span>
))}
</div>
</>
)}
</Panel>
<Panel title="Object types" subtitle="Objects by parsed type">
{typesQuery.isError ? (
<ErrorBlock error={typesQuery.error} onRetry={() => typesQuery.refetch()} />
) : typesQuery.isPending ? (
<LoadingBlock />
) : typeData.length === 0 ? (
<p className="text-muted">No object type stats recorded.</p>
) : (
<div className="chart-box">
<ResponsiveContainer>
<BarChart data={typeData} layout="vertical" margin={{ left: 12, right: 24 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={104}
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "var(--text-2)" }}
/>
<Tooltip formatter={(value) => formatInt(Number(value))} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={16}>
{typeData.map((entry, index) => (
<Cell key={entry.name} fill={TYPE_PALETTE[index % TYPE_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</Panel>
</div>
<div className="overview-bottom">
<Panel
title="Top repositories"
subtitle="First repositories by index order"
tools={
<Link className="btn small" to={withRunParam("/repositories", runId)}>
View all
</Link>
}
flush
>
{reposQuery.isError ? (
<div className="panel-body">
<ErrorBlock error={reposQuery.error} onRetry={() => reposQuery.refetch()} />
</div>
) : (
<DataTable
columns={repoColumns}
rows={reposQuery.data?.items ?? []}
rowKey={(repo) => repo.repoId}
loading={reposQuery.isPending}
emptyTitle="No repositories indexed"
onRowClick={(repo) =>
navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId))
}
/>
)}
</Panel>
<Panel
title="Top reject reasons"
subtitle="Click a reason to inspect matching objects"
tools={
<Link className="btn small" to={withRunParam("/validation", runId)}>
Validation
</Link>
}
>
{reasonsQuery.isError ? (
<ErrorBlock error={reasonsQuery.error} onRetry={() => reasonsQuery.refetch()} />
) : reasonsQuery.isPending ? (
<LoadingBlock />
) : reasonRows.length === 0 ? (
<p className="text-muted">No rejected objects in this run.</p>
) : (
<div className="reason-list">
{reasonRows.map((row) => (
<Link
key={row.reason}
className="reason-row"
to={withRunParam(`/validation?reason=${encodeURIComponent(row.reason)}`, runId)}
title={row.reason}
>
<span className="reason-text">{row.reason}</span>
<span className="reason-count">{formatInt(row.count)}</span>
<span className="reason-bar" aria-hidden="true">
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
</span>
</Link>
))}
</div>
)}
</Panel>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
getPublicationPoint,
listPublicationPointObjects,
} from "../api/service";
import { CopyableValue } from "../components/CopyableValue";
import { KpiCard } from "../components/KpiCard";
import {
EMPTY_OBJECT_FILTERS,
ObjectsTable,
type ObjectFilterValues,
} from "../components/ObjectsTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
import { formatDurationMs, formatInt, formatUtc } from "../lib/format";
import { withRunParam } from "../lib/run";
import { useRun } from "../lib/useRun";
export default function PublicationPointDetailPage() {
const { ppId = "" } = useParams();
const { runId } = useRun();
const [objectFilters, setObjectFilters] = useState<ObjectFilterValues>(EMPTY_OBJECT_FILTERS);
const ppQuery = useQuery({
queryKey: ["pp", runId, ppId],
queryFn: () => getPublicationPoint(runId, ppId),
});
if (ppQuery.isError) {
return (
<div className="page">
<PageHeader title="Publication point" />
<ErrorBlock error={ppQuery.error} onRetry={() => ppQuery.refetch()} title="Failed to load publication point" />
</div>
);
}
if (ppQuery.isPending) {
return (
<div className="page">
<PageHeader title="Publication point" />
<LoadingBlock />
</div>
);
}
const pp = ppQuery.data;
return (
<div className="page">
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link to={withRunParam("/publication-points", runId)}>Publication Points</Link>
<span aria-hidden="true">/</span>
<span className="mono">{pp.ppId}</span>
</nav>
<PageHeader
title={<span className="mono" style={{ fontSize: 16 }}>{pp.manifestRsyncUri ?? pp.ppId}</span>}
subtitle={pp.repoSyncError ? `Sync error: ${pp.repoSyncError}` : undefined}
actions={<StatusPill status={pp.repoTerminalState} label={`terminal ${pp.repoTerminalState ?? "unknown"}`} />}
/>
<div className="kpi-grid">
<KpiCard label="Objects" value={formatInt(pp.objects)} />
<KpiCard label="Rejected" value={formatInt(pp.rejectedObjects)} tone="red" />
<KpiCard label="Warnings" value={formatInt(pp.warnings)} tone="amber" />
<KpiCard label="Sync time" value={formatDurationMs(pp.repoSyncDurationMs)} />
</div>
<Panel title="Publication point details">
<dl className="meta-grid">
<dt>PP ID</dt>
<dd><CopyableValue value={pp.ppId} max={48} label="publication point id" /></dd>
<dt>Repository</dt>
<dd>
<Link to={withRunParam(`/repositories/${encodeURIComponent(pp.repoId)}`, runId)} className="mono">
{pp.repoId}
</Link>
</dd>
<dt>Manifest URI</dt>
<dd><CopyableValue value={pp.manifestRsyncUri} max={96} label="manifest URI" /></dd>
<dt>PP rsync URI</dt>
<dd><CopyableValue value={pp.publicationPointRsyncUri} max={96} label="publication point rsync URI" /></dd>
<dt>rsync base</dt>
<dd><CopyableValue value={pp.rsyncBaseUri} max={96} label="rsync base URI" /></dd>
<dt>RRDP notification</dt>
<dd><CopyableValue value={pp.rrdpNotificationUri} max={96} label="RRDP notification URI" /></dd>
<dt>Sync source</dt>
<dd>{pp.repoSyncSource ?? pp.source ?? "—"}</dd>
<dt>Sync phase</dt>
<dd>{pp.repoSyncPhase ?? "—"}</dd>
<dt>Terminal state</dt>
<dd><StatusPill status={pp.repoTerminalState} /></dd>
<dt>thisUpdate</dt>
<dd>{formatUtc(pp.thisUpdate)}</dd>
<dt>nextUpdate</dt>
<dd>{formatUtc(pp.nextUpdate)}</dd>
</dl>
</Panel>
<Panel title="Objects" flush>
<ObjectsTable
runId={runId}
queryKeyScope={`pp:${ppId}`}
fetcher={(filters) => listPublicationPointObjects(runId, ppId, filters)}
filters={objectFilters}
onFiltersChange={setObjectFilters}
/>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { listPublicationPoints } from "../api/service";
import type { PublicationPointRecord } from "../api/schemas";
import { CursorPagerControls } from "../components/CursorPagerControls";
import { DataTable, type Column } from "../components/DataTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { useCursorPager } from "../lib/cursor";
import { formatDurationMs, formatInt, truncateMiddle } from "../lib/format";
import { withRunParam } from "../lib/run";
import { useRun } from "../lib/useRun";
export default function PublicationPointsPage() {
const { runId } = useRun();
const navigate = useNavigate();
const pager = useCursorPager(runId);
const [needle, setNeedle] = useState("");
const ppsQuery = useQuery({
queryKey: ["pps", runId, pager.cursor],
queryFn: () => listPublicationPoints(runId, { limit: 50, cursor: pager.cursor }),
placeholderData: (prev) => prev,
});
const rows = useMemo(() => {
const items = ppsQuery.data?.items ?? [];
const q = needle.trim().toLowerCase();
if (!q) return items;
return items.filter((pp) =>
[pp.manifestRsyncUri, pp.publicationPointRsyncUri, pp.rrdpNotificationUri, pp.rsyncBaseUri, pp.ppId]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(q)),
);
}, [ppsQuery.data, needle]);
const columns: Column<PublicationPointRecord>[] = [
{
key: "manifest",
header: "Publication point",
render: (pp) => (
<div>
<div className="cell-main mono text-small ellipsis" style={{ maxWidth: 380 }} title={pp.manifestRsyncUri ?? pp.ppId}>
{pp.manifestRsyncUri ? truncateMiddle(pp.manifestRsyncUri, 68) : pp.ppId}
</div>
<div className="text-faint text-small mono">{pp.ppId}</div>
</div>
),
},
{
key: "source",
header: "Sync source",
render: (pp) => <span className="chip">{pp.repoSyncSource ?? pp.source ?? "—"}</span>,
},
{
key: "state",
header: "Terminal state",
render: (pp) => <StatusPill status={pp.repoTerminalState} />,
},
{ key: "objects", header: "Objects", numeric: true, render: (pp) => formatInt(pp.objects) },
{
key: "rejected",
header: "Rejected",
numeric: true,
render: (pp) =>
pp.rejectedObjects ? (
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(pp.rejectedObjects)}</span>
) : (
"0"
),
},
{ key: "warnings", header: "Warnings", numeric: true, render: (pp) => formatInt(pp.warnings) },
{
key: "duration",
header: "Sync time",
numeric: true,
render: (pp) => formatDurationMs(pp.repoSyncDurationMs),
},
];
return (
<div className="page">
<PageHeader title="Publication Points" subtitle="All publication points indexed for the active run" />
<Panel
title="All publication points"
tools={
<input
type="search"
value={needle}
onChange={(e) => setNeedle(e.target.value)}
placeholder="Filter current page (URI/id)…"
aria-label="Filter current page"
style={{ minWidth: 220, padding: "5px 8px", border: "1px solid var(--border)", borderRadius: "var(--radius-m)" }}
/>
}
flush
>
<DataTable
columns={columns}
rows={rows}
rowKey={(pp) => pp.ppId}
loading={ppsQuery.isPending}
error={ppsQuery.isError ? ppsQuery.error : undefined}
onRetry={() => ppsQuery.refetch()}
emptyTitle="No publication points indexed"
caption="Publication points"
onRowClick={(pp) =>
navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId))
}
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={ppsQuery.data?.nextCursor != null}
loading={ppsQuery.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(ppsQuery.data?.nextCursor ?? null)}
itemCount={rows.length}
/>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { listRepos } from "../api/service";
import type { RepositoryRecord } from "../api/schemas";
import { CursorPagerControls } from "../components/CursorPagerControls";
import { DataTable, type Column } from "../components/DataTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { useCursorPager } from "../lib/cursor";
import { formatBytes, formatDurationMs, formatInt, truncateMiddle } from "../lib/format";
import { withRunParam } from "../lib/run";
import { useRun } from "../lib/useRun";
export default function RepositoriesPage() {
const { runId, runQuery } = useRun();
const navigate = useNavigate();
const pager = useCursorPager(runId);
const [needle, setNeedle] = useState("");
const reposQuery = useQuery({
queryKey: ["repos", runId, pager.cursor],
queryFn: () => listRepos(runId, { limit: 50, cursor: pager.cursor }),
placeholderData: (prev) => prev,
});
const rows = useMemo(() => {
const items = reposQuery.data?.items ?? [];
const q = needle.trim().toLowerCase();
if (!q) return items;
return items.filter(
(repo) =>
repo.host.toLowerCase().includes(q) || repo.uri.toLowerCase().includes(q),
);
}, [reposQuery.data, needle]);
const columns: Column<RepositoryRecord>[] = [
{
key: "host",
header: "Repository",
render: (repo) => (
<div>
<div className="cell-main">{repo.host}</div>
<div className="text-faint text-small mono ellipsis" style={{ maxWidth: 320 }} title={repo.uri}>
{truncateMiddle(repo.uri, 64)}
</div>
</div>
),
},
{
key: "transport",
header: "Transport",
render: (repo) => <span className="chip">{repo.transport ?? "unknown"}</span>,
},
{ key: "pps", header: "PPs", numeric: true, render: (repo) => formatInt(repo.publicationPoints) },
{ key: "objects", header: "Objects", numeric: true, render: (repo) => formatInt(repo.objects) },
{
key: "rejected",
header: "Rejected",
numeric: true,
render: (repo) =>
repo.rejectedObjects ? (
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(repo.rejectedObjects)}</span>
) : (
"0"
),
},
{ key: "bytes", header: "Downloaded", numeric: true, render: (repo) => formatBytes(repo.downloadBytes) },
{
key: "duration",
header: "Sync time",
numeric: true,
render: (repo) => formatDurationMs(repo.syncDurationMsTotal),
},
{
key: "terminal",
header: "Terminal states",
render: (repo) => (
<span className="chip-list">
{Object.entries(repo.terminalStates ?? {}).map(([state, count]) => (
<StatusPill key={state} status={state} label={`${state} ${formatInt(count)}`} />
))}
</span>
),
},
];
return (
<div className="page">
<PageHeader
title="Repositories"
subtitle={
runQuery.data
? `${formatInt(runQuery.data.counts?.objects)} objects across all repositories in ${runQuery.data.runId}`
: undefined
}
/>
<Panel
title="All repositories"
tools={
<div className="filter-field">
<label htmlFor="repo-filter" className="sr-only">
Filter current page
</label>
<input
id="repo-filter"
type="search"
value={needle}
onChange={(e) => setNeedle(e.target.value)}
placeholder="Filter current page (host/URI)…"
style={{ minWidth: 220 }}
/>
</div>
}
flush
>
<DataTable
columns={columns}
rows={rows}
rowKey={(repo) => repo.repoId}
loading={reposQuery.isPending}
error={reposQuery.isError ? reposQuery.error : undefined}
onRetry={() => reposQuery.refetch()}
emptyTitle="No repositories indexed"
emptyHint="The query service has not indexed any run yet."
caption="Repositories"
onRowClick={(repo) =>
navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId))
}
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={reposQuery.data?.nextCursor != null}
loading={reposQuery.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(reposQuery.data?.nextCursor ?? null)}
itemCount={rows.length}
/>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
getRepo,
getRepoStats,
listRepoObjects,
listRepoPublicationPoints,
} from "../api/service";
import type { PublicationPointRecord } from "../api/schemas";
import { CopyableValue } from "../components/CopyableValue";
import { CursorPagerControls } from "../components/CursorPagerControls";
import { DataTable, type Column } from "../components/DataTable";
import { KpiCard } from "../components/KpiCard";
import {
EMPTY_OBJECT_FILTERS,
ObjectsTable,
type ObjectFilterValues,
} from "../components/ObjectsTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { TabPanel, Tabs } from "../components/Tabs";
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
import { useCursorPager } from "../lib/cursor";
import { formatDurationMs, formatInt, formatUtc, truncateMiddle } from "../lib/format";
import { withRunParam } from "../lib/run";
import { useRun } from "../lib/useRun";
function RepoPpsTable({ runId, repoId }: { runId: string; repoId: string }) {
const navigate = useNavigate();
const pager = useCursorPager(`${runId}:${repoId}`);
const ppsQuery = useQuery({
queryKey: ["repo-pps", runId, repoId, pager.cursor],
queryFn: () => listRepoPublicationPoints(runId, repoId, { limit: 50, cursor: pager.cursor }),
placeholderData: (prev) => prev,
});
const columns: Column<PublicationPointRecord>[] = [
{
key: "manifest",
header: "Manifest URI",
render: (pp) => (
<span className="mono text-small ellipsis" style={{ display: "block", maxWidth: 420 }} title={pp.manifestRsyncUri ?? undefined}>
{pp.manifestRsyncUri ? truncateMiddle(pp.manifestRsyncUri, 72) : pp.ppId}
</span>
),
},
{
key: "source",
header: "Sync source",
render: (pp) => <span className="chip">{pp.repoSyncSource ?? pp.source ?? "—"}</span>,
},
{
key: "state",
header: "Terminal state",
render: (pp) => <StatusPill status={pp.repoTerminalState} />,
},
{ key: "objects", header: "Objects", numeric: true, render: (pp) => formatInt(pp.objects) },
{
key: "rejected",
header: "Rejected",
numeric: true,
render: (pp) =>
pp.rejectedObjects ? (
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(pp.rejectedObjects)}</span>
) : (
"0"
),
},
{
key: "duration",
header: "Sync time",
numeric: true,
render: (pp) => formatDurationMs(pp.repoSyncDurationMs),
},
];
return (
<>
<DataTable
columns={columns}
rows={ppsQuery.data?.items ?? []}
rowKey={(pp) => pp.ppId}
loading={ppsQuery.isPending}
error={ppsQuery.isError ? ppsQuery.error : undefined}
onRetry={() => ppsQuery.refetch()}
emptyTitle="No publication points"
caption="Publication points in this repository"
onRowClick={(pp) =>
navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId))
}
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={ppsQuery.data?.nextCursor != null}
loading={ppsQuery.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(ppsQuery.data?.nextCursor ?? null)}
itemCount={ppsQuery.data?.items.length}
/>
</>
);
}
export default function RepositoryDetailPage() {
const { repoId = "" } = useParams();
const { runId, runQuery } = useRun();
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get("tab") ?? "pps";
const [objectFilters, setObjectFilters] = useState<ObjectFilterValues>(EMPTY_OBJECT_FILTERS);
const repoQuery = useQuery({
queryKey: ["repo", runId, repoId],
queryFn: () => getRepo(runId, repoId),
});
const statsQuery = useQuery({
queryKey: ["repo-stats", runId, repoId],
queryFn: () => getRepoStats(runId, repoId),
});
if (repoQuery.isError) {
return (
<div className="page">
<PageHeader title="Repository" />
<ErrorBlock error={repoQuery.error} onRetry={() => repoQuery.refetch()} title="Failed to load repository" />
</div>
);
}
if (repoQuery.isPending) {
return (
<div className="page">
<PageHeader title="Repository" />
<LoadingBlock />
</div>
);
}
const repo = repoQuery.data;
const stats = statsQuery.data;
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("/repositories", runId)}>Repositories</Link>
<span aria-hidden="true">/</span>
<span>{repo.host}</span>
</nav>
<PageHeader
title={repo.host}
subtitle={<CopyableValue value={repo.uri} max={96} label="repository URI" />}
actions={<StatusPill status={runQuery.data?.indexStatus} label={`run ${runQuery.data?.runId ?? runId}`} />}
/>
<div className="kpi-grid">
<KpiCard label="Publication points" value={formatInt(repo.publicationPoints)} />
<KpiCard label="Objects" value={formatInt(repo.objects)} />
<KpiCard label="Rejected" value={formatInt(repo.rejectedObjects)} tone="red" />
<KpiCard label="Sync time" value={formatDurationMs(repo.syncDurationMsTotal)} />
</div>
<Panel title="Repository details">
<dl className="meta-grid">
<dt>Repository ID</dt>
<dd><CopyableValue value={repo.repoId} max={48} label="repository id" /></dd>
<dt>URI</dt>
<dd><CopyableValue value={repo.uri} max={96} label="repository URI" /></dd>
<dt>Host</dt>
<dd>{repo.host}</dd>
<dt>Transport</dt>
<dd><span className="chip">{repo.transport ?? "unknown"}</span></dd>
<dt>Sync phases</dt>
<dd>
<span className="chip-list">
{Object.entries(stats?.phases ?? repo.phases ?? {}).map(([phase, count]) => (
<span className="chip" key={phase}>{phase} {formatInt(count)}</span>
))}
{Object.keys(stats?.phases ?? repo.phases ?? {}).length === 0 && <span className="text-faint">—</span>}
</span>
</dd>
<dt>Terminal states</dt>
<dd>
<span className="chip-list">
{Object.entries(stats?.terminalStates ?? repo.terminalStates ?? {}).map(([state, count]) => (
<StatusPill key={state} status={state} label={`${state} ${formatInt(count)}`} />
))}
{Object.keys(stats?.terminalStates ?? repo.terminalStates ?? {}).length === 0 && <span className="text-faint">—</span>}
</span>
</dd>
<dt>Run validated</dt>
<dd>{formatUtc(runQuery.data?.validationTime)}</dd>
</dl>
</Panel>
<Panel flush>
<Tabs
tabs={[
{ id: "pps", label: "Publication points" },
{ id: "objects", label: "Objects" },
]}
active={tab}
onChange={setTab}
ariaLabel="Repository sections"
/>
<TabPanel id="pps" active={tab}>
<RepoPpsTable runId={runId} repoId={repoId} />
</TabPanel>
<TabPanel id="objects" active={tab}>
<ObjectsTable
runId={runId}
queryKeyScope={`repo:${repoId}`}
fetcher={(filters) => listRepoObjects(runId, repoId, filters)}
filters={objectFilters}
onFiltersChange={setObjectFilters}
/>
</TabPanel>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getRunArtifacts, listRuns } from "../api/service";
import type { RunRecord } from "../api/schemas";
import { CopyableValue } from "../components/CopyableValue";
import { CursorPagerControls } from "../components/CursorPagerControls";
import { DataTable, type Column } from "../components/DataTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { useCursorPager } from "../lib/cursor";
import { formatDurationMs, formatInt, formatRelative, formatUtc } from "../lib/format";
import { useRunParam } from "../lib/run";
function RunArtifacts({ runId }: { runId: string }) {
const artifactsQuery = useQuery({
queryKey: ["run-artifacts", runId],
queryFn: () => getRunArtifacts(runId),
staleTime: 5 * 60_000,
});
if (artifactsQuery.isPending) return <div className="run-artifacts">Loading artifacts…</div>;
if (artifactsQuery.isError) {
return <div className="run-artifacts">Failed to load artifacts: {artifactsQuery.error.message}</div>;
}
const entries = Object.entries(artifactsQuery.data).sort(([a], [b]) => a.localeCompare(b));
return (
<div className="run-artifacts">
<ul>
{entries.map(([name, path]) => (
<li key={name}>
<span className="text-muted">{name}</span>
<CopyableValue value={path} max={90} label={`artifact ${name}`} />
</li>
))}
</ul>
</div>
);
}
export default function RunsPage() {
const navigate = useNavigate();
const runParam = useRunParam();
const pager = useCursorPager("runs");
const [expandedRun, setExpandedRun] = useState<string | null>(null);
const runsQuery = useQuery({
queryKey: ["runs", "page", pager.cursor],
queryFn: () => listRuns({ limit: 25, cursor: pager.cursor }),
placeholderData: (prev) => prev,
});
const columns: Column<RunRecord>[] = [
{
key: "run",
header: "Run",
render: (run) => (
<div>
<div className="cell-main mono">{run.runId}</div>
<div className="text-faint text-small">seq {run.runSeq ?? "—"}</div>
</div>
),
},
{
key: "validated",
header: "Validated",
render: (run) => <span title={formatUtc(run.validationTime)}>{formatRelative(run.validationTime)}</span>,
},
{ key: "mode", header: "Sync mode", render: (run) => <span className="chip">{run.syncMode ?? "—"}</span> },
{ key: "wall", header: "Wall time", numeric: true, render: (run) => formatDurationMs(run.wallMs) },
{ key: "objects", header: "Objects", numeric: true, render: (run) => formatInt(run.counts?.objects) },
{ key: "vrps", header: "VRPs", numeric: true, render: (run) => formatInt(run.counts?.vrps) },
{
key: "rejected",
header: "Rejected",
numeric: true,
render: (run) =>
run.counts?.rejectedObjects ? (
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(run.counts.rejectedObjects)}</span>
) : (
"0"
),
},
{ key: "index", header: "Index", render: (run) => <StatusPill status={run.indexStatus} /> },
{
key: "actions",
header: "",
render: (run) => {
const active = runParam === run.runId;
return (
<span style={{ display: "inline-flex", gap: 6 }} onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="btn small"
disabled={active}
onClick={() => navigate(`/?run=${encodeURIComponent(run.runId)}`)}
>
{active ? "Active" : "Use run"}
</button>
<button
type="button"
className="btn small"
onClick={() => setExpandedRun((prev) => (prev === run.runId ? null : run.runId))}
aria-expanded={expandedRun === run.runId}
>
Artifacts
</button>
</span>
);
},
},
];
return (
<div className="page">
<PageHeader
title="Runs"
subtitle="Validation runs indexed by the query service (retention: last 10 by default)"
/>
<Panel title="Run history" flush>
<DataTable
columns={columns}
rows={runsQuery.data?.items ?? []}
rowKey={(run) => run.runId}
loading={runsQuery.isPending}
error={runsQuery.isError ? runsQuery.error : undefined}
onRetry={() => runsQuery.refetch()}
emptyTitle="No runs indexed"
caption="Validation runs"
/>
{expandedRun ? <RunArtifacts runId={expandedRun} /> : null}
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={runsQuery.data?.nextCursor != null}
loading={runsQuery.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(runsQuery.data?.nextCursor ?? null)}
itemCount={runsQuery.data?.items.length}
/>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { useEffect, useState, type FormEvent } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { FileBox, FolderTree, Server } from "lucide-react";
import { searchRun } from "../api/service";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock";
import { objectTypeLabel } from "../lib/format";
import { withRunParam } from "../lib/run";
import { useRun } from "../lib/useRun";
/** Detect queries the backend cannot serve yet (VRP lookup = backlog #070). */
function looksLikeNetworkQuery(q: string): boolean {
return (
/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(q) || // IPv4 / prefix
/^[0-9a-fA-F:]*:[0-9a-fA-F:.]*(\/\d{1,3})?$/.test(q) || // IPv6-ish
/^(AS)?\d{1,10}$/i.test(q) // ASN
);
}
export default function SearchPage() {
const { runId } = useRun();
const [searchParams, setSearchParams] = useSearchParams();
const q = searchParams.get("q") ?? "";
const [draft, setDraft] = useState(q);
useEffect(() => setDraft(q), [q]);
const searchQuery = useQuery({
queryKey: ["search", runId, q],
queryFn: () => searchRun(runId, q, 10),
enabled: q.trim().length > 0,
});
const onSubmit = (event: FormEvent) => {
event.preventDefault();
const params = new URLSearchParams(searchParams);
if (draft.trim()) params.set("q", draft.trim());
else params.delete("q");
setSearchParams(params, { preventScrollReset: true });
};
const result = searchQuery.data;
const totalHits =
(result?.objects.length ?? 0) +
(result?.repos.length ?? 0) +
(result?.publicationPoints.length ?? 0);
return (
<div className="page">
<PageHeader
title="Search"
subtitle="Exact object URI, SHA-256 (full or ≥8 hex prefix), repository host, or publication point URI"
/>
<form className="search-hero" role="search" onSubmit={onSubmit}>
<input
type="search"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="rsync://… or 64-char sha256 or repository host…"
aria-label="Search query"
autoFocus
/>
<button type="submit" className="btn primary">
Search
</button>
</form>
{q && looksLikeNetworkQuery(q) ? (
<Notice kind="info">
IP prefix and ASN lookup of VRPs requires backend feature #070 (VRP lookup),
which is not implemented yet. The results below only cover objects,
repositories and publication points.
</Notice>
) : null}
{!q ? (
<EmptyBlock
title="Enter a query"
hint="Paste an object URI to jump straight to its detail page, or a hash / host to find matching entities."
/>
) : searchQuery.isError ? (
<ErrorBlock error={searchQuery.error} onRetry={() => searchQuery.refetch()} title="Search failed" />
) : searchQuery.isPending ? (
<EmptyBlock title="Searching…" />
) : !result || totalHits === 0 ? (
<EmptyBlock title={`No results for “${q}”`} hint="URI search is exact; hash search accepts a prefix of 8+ hex characters." />
) : (
<>
{result.objects.length > 0 ? (
<section className="result-group">
<h3>Objects ({result.objects.length})</h3>
{result.objects.map((obj) => (
<Link
key={obj.objectInstanceId}
className="result-item"
to={withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId)}
>
<span className="result-kind">
<FileBox size={14} aria-hidden="true" /> {objectTypeLabel(obj.objectType)}
</span>
<span className="result-main">
<span className="result-title">{obj.uri}</span>
<span className="result-sub mono">sha256 {obj.sha256 ?? "—"}</span>
</span>
<StatusPill status={obj.rejected ? "rejected" : obj.result} />
</Link>
))}
</section>
) : null}
{result.repos.length > 0 ? (
<section className="result-group">
<h3>Repositories ({result.repos.length})</h3>
{result.repos.map((repo) => (
<Link
key={repo.repoId}
className="result-item"
to={withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId)}
>
<span className="result-kind">
<Server size={14} aria-hidden="true" /> repo
</span>
<span className="result-main">
<span className="result-title">{repo.host}</span>
<span className="result-sub mono">{repo.uri}</span>
</span>
<span className="chip">{repo.transport ?? "unknown"}</span>
</Link>
))}
</section>
) : null}
{result.publicationPoints.length > 0 ? (
<section className="result-group">
<h3>Publication points ({result.publicationPoints.length})</h3>
{result.publicationPoints.map((pp) => (
<Link
key={pp.ppId}
className="result-item"
to={withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId)}
>
<span className="result-kind">
<FolderTree size={14} aria-hidden="true" /> PP
</span>
<span className="result-main">
<span className="result-title">{pp.manifestRsyncUri ?? pp.ppId}</span>
<span className="result-sub mono">{pp.ppId}</span>
</span>
<StatusPill status={pp.repoTerminalState} />
</Link>
))}
</section>
) : null}
</>
)}
<Panel title="Search syntax">
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
<li><code>rsync://</code> / <code>https://</code> URI — exact object match</li>
<li>64 hex chars — object by SHA-256; 8+ hex chars — SHA-256 prefix scan</li>
<li>any other text — substring match on repository host/URI and publication point URIs</li>
<li>IP / prefix / ASN VRP lookup is planned under backend feature #070</li>
</ul>
</Panel>
</div>
);
}

View File

@@ -0,0 +1,272 @@
import { useMemo } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQueries, useQuery } from "@tanstack/react-query";
import {
getStatsReasons,
getStatsValidation,
listIssues,
listRepos,
} from "../api/service";
import { KpiCard } from "../components/KpiCard";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
import { CursorPagerControls } from "../components/CursorPagerControls";
import { DataTable, type Column } from "../components/DataTable";
import { CopyableValue } from "../components/CopyableValue";
import type { ObjectInstanceRecord } from "../api/schemas";
import { useCursorPager } from "../lib/cursor";
import { formatInt, formatPercent, objectTypeLabel, truncateMiddle } from "../lib/format";
import { withRunParam } from "../lib/run";
import { useRun } from "../lib/useRun";
import { useNavigate } from "react-router-dom";
export default function ValidationPage() {
const { runId } = useRun();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const reason = searchParams.get("reason") ?? "";
const type = searchParams.get("type") ?? "";
const repoId = searchParams.get("repoId") ?? "";
const filterKey = JSON.stringify([runId, reason, type, repoId]);
const pager = useCursorPager(filterKey);
const [validationQuery, reasonsQuery, reposQuery] = useQueries({
queries: [
{
queryKey: ["stats", runId, "validation"],
queryFn: () => getStatsValidation(runId),
staleTime: 60_000,
},
{
queryKey: ["stats", runId, "reasons"],
queryFn: () => getStatsReasons(runId),
staleTime: 60_000,
},
{
queryKey: ["repos", runId, "filter-options"],
queryFn: () => listRepos(runId, { limit: 200 }),
staleTime: 5 * 60_000,
},
],
});
const issuesQuery = useQuery({
queryKey: ["issues", runId, reason, type, repoId, pager.cursor],
queryFn: () =>
listIssues(runId, {
limit: 50,
cursor: pager.cursor,
reason: reason || undefined,
type: type || undefined,
repoId: repoId || undefined,
}),
placeholderData: (prev) => prev,
});
const validationEntries = useMemo(() => {
const map = validationQuery.data ?? {};
return Object.entries(map).sort((a, b) => b[1] - a[1]);
}, [validationQuery.data]);
const validationTotal = validationEntries.reduce((sum, [, n]) => sum + n, 0);
const reasonRows = useMemo(() => {
const map = reasonsQuery.data ?? {};
return Object.entries(map)
.map(([text, count]) => ({ text, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
}, [reasonsQuery.data]);
const maxReason = reasonRows[0]?.count ?? 0;
const setParam = (key: string, value: string) => {
const params = new URLSearchParams(searchParams);
if (value) params.set(key, value);
else params.delete(key);
setSearchParams(params, { preventScrollReset: true });
};
const columns: Column<ObjectInstanceRecord>[] = [
{
key: "type",
header: "Type",
render: (obj) => <span className="chip">{objectTypeLabel(obj.objectType)}</span>,
},
{
key: "uri",
header: "URI",
render: (obj) => <CopyableValue value={obj.uri} max={60} label="object URI" />,
},
{
key: "result",
header: "Result",
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
},
{
key: "reason",
header: "Reject reason",
render: (obj) =>
obj.rejectReason ? (
<span className="mono text-small" title={obj.rejectReason}>
{truncateMiddle(obj.rejectReason, 72)}
</span>
) : (
<span className="text-faint">—</span>
),
},
{
key: "source",
header: "Source",
render: (obj) => <StatusPill status={obj.sourceSection} />,
},
];
return (
<div className="page">
<PageHeader
title="Validation"
subtitle="Rejected and errored objects for the active run, with reason distribution"
/>
<div className="validation-summary">
<Panel title="Result distribution" subtitle="Share of audited objects">
{validationQuery.isError ? (
<ErrorBlock error={validationQuery.error} onRetry={() => validationQuery.refetch()} />
) : validationQuery.isPending ? (
<LoadingBlock />
) : validationEntries.length === 0 ? (
<p className="text-muted">No validation stats recorded.</p>
) : (
<div className="reason-list">
{validationEntries.map(([name, count]) => (
<div className="reason-row" key={name}>
<span className="reason-text" style={{ fontFamily: "inherit" }}>
<StatusPill status={name} />
</span>
<span className="reason-count">
{formatInt(count)} · {formatPercent(count, validationTotal)}
</span>
</div>
))}
</div>
)}
</Panel>
<Panel title="Reject reasons" subtitle="Click to filter the issue list below">
{reasonsQuery.isError ? (
<ErrorBlock error={reasonsQuery.error} onRetry={() => reasonsQuery.refetch()} />
) : reasonsQuery.isPending ? (
<LoadingBlock />
) : reasonRows.length === 0 ? (
<p className="text-muted">No rejected objects in this run.</p>
) : (
<div className="reason-list">
{reasonRows.map((row) => (
<button
key={row.text}
type="button"
className="reason-row"
style={{ border: "none", background: "none", cursor: "pointer", textAlign: "left" }}
onClick={() => setParam("reason", row.text === reason ? "" : row.text)}
title={row.text}
aria-pressed={row.text === reason}
>
<span className="reason-text">{row.text}</span>
<span className="reason-count">{formatInt(row.count)}</span>
<span className="reason-bar" aria-hidden="true">
<span style={{ width: `${maxReason ? (row.count / maxReason) * 100 : 0}%` }} />
</span>
</button>
))}
</div>
)}
</Panel>
</div>
<Panel
title="Validation issues"
subtitle="Objects that were rejected or failed validation"
tools={
<div className="filter-bar">
<div className="filter-field">
<label htmlFor="issue-type">Type</label>
<select id="issue-type" value={type} onChange={(e) => setParam("type", e.target.value)}>
<option value="">all</option>
{["roa", "manifest", "crl", "certificate", "aspa", "gbr"].map((t) => (
<option key={t} value={t}>{objectTypeLabel(t)}</option>
))}
</select>
</div>
<div className="filter-field">
<label htmlFor="issue-repo">Repository</label>
<select id="issue-repo" value={repoId} onChange={(e) => setParam("repoId", e.target.value)}>
<option value="">all</option>
{(reposQuery.data?.items ?? []).map((repo) => (
<option key={repo.repoId} value={repo.repoId}>
{repo.host}
</option>
))}
</select>
</div>
<div className="filter-field">
<label htmlFor="issue-reason">Reason contains</label>
<input
id="issue-reason"
type="text"
value={reason}
onChange={(e) => setParam("reason", e.target.value)}
placeholder="substring…"
className="mono"
/>
</div>
{(reason || type || repoId) && (
<Link className="btn small" to={withRunParam("/validation", runId)}>
Clear filters
</Link>
)}
</div>
}
flush
>
<DataTable
columns={columns}
rows={issuesQuery.data?.items ?? []}
rowKey={(obj) => obj.objectInstanceId}
loading={issuesQuery.isPending}
error={issuesQuery.isError ? issuesQuery.error : undefined}
onRetry={() => issuesQuery.refetch()}
emptyTitle="No validation issues"
emptyHint="Nothing was rejected or errored in this run (or filters are too strict)."
caption="Validation issues"
onRowClick={(obj) =>
navigate(withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId))
}
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={issuesQuery.data?.nextCursor != null}
loading={issuesQuery.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(issuesQuery.data?.nextCursor ?? null)}
itemCount={issuesQuery.data?.items.length}
/>
</Panel>
<div className="kpi-grid">
<KpiCard
label="Issues on this page"
value={formatInt(issuesQuery.data?.items.length)}
tone="red"
/>
<KpiCard
label="Distinct reasons"
value={formatInt(Object.keys(reasonsQuery.data ?? {}).length)}
tone="amber"
/>
</div>
</div>
);
}