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

273 lines
9.7 KiB
TypeScript

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