20260718 重构 RPKI Explorer 查询界面
This commit is contained in:
111
ui/rpki-explorer/src/pages/ApiStatusPage.tsx
Normal file
111
ui/rpki-explorer/src/pages/ApiStatusPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
ui/rpki-explorer/src/pages/ExportsPage.tsx
Normal file
100
ui/rpki-explorer/src/pages/ExportsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
ui/rpki-explorer/src/pages/NotFoundPage.tsx
Normal file
17
ui/rpki-explorer/src/pages/NotFoundPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
393
ui/rpki-explorer/src/pages/ObjectDetailPage.tsx
Normal file
393
ui/rpki-explorer/src/pages/ObjectDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
ui/rpki-explorer/src/pages/ObjectsPage.tsx
Normal file
71
ui/rpki-explorer/src/pages/ObjectsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
381
ui/rpki-explorer/src/pages/OverviewPage.tsx
Normal file
381
ui/rpki-explorer/src/pages/OverviewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
ui/rpki-explorer/src/pages/PublicationPointDetailPage.tsx
Normal file
115
ui/rpki-explorer/src/pages/PublicationPointDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
ui/rpki-explorer/src/pages/PublicationPointsPage.tsx
Normal file
125
ui/rpki-explorer/src/pages/PublicationPointsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
ui/rpki-explorer/src/pages/RepositoriesPage.tsx
Normal file
144
ui/rpki-explorer/src/pages/RepositoriesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx
Normal file
228
ui/rpki-explorer/src/pages/RepositoryDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
ui/rpki-explorer/src/pages/RunsPage.tsx
Normal file
145
ui/rpki-explorer/src/pages/RunsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
ui/rpki-explorer/src/pages/SearchPage.tsx
Normal file
171
ui/rpki-explorer/src/pages/SearchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
ui/rpki-explorer/src/pages/ValidationPage.tsx
Normal file
272
ui/rpki-explorer/src/pages/ValidationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user