273 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|