20260728_2 修复rpki-explorer体验问题7项(#134):证书资源可展开、CMS对象新增EE Parsed Tab、Overview增加repo数KPI/修复Object types标签/Top repositories按objects排序(后端sort参数)/reject reasons按前缀聚类可下钻、Run选择器30s轮询

This commit is contained in:
2026-07-28 13:21:58 +08:00
parent 0d6bbec9f3
commit f7f80fe684
20 changed files with 676 additions and 70 deletions

View File

@@ -16,12 +16,13 @@ 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 { CertificateView, ProjectionView } from "../components/ProjectionView";
import { StatusPill } from "../components/StatusPill";
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
import { TabPanel, Tabs } from "../components/Tabs";
import { WorkflowStatus } from "../components/WorkflowStatus";
import { formatInt, objectTypeLabel } from "../lib/format";
import { eeCertificateProjection } from "../lib/projection";
import { withRunParam } from "../lib/run";
import { useExportJob } from "../lib/useExportJob";
import { useRun } from "../lib/useRun";
@@ -212,10 +213,11 @@ export default function ObjectDetailPage() {
queryFn: () => getObject(runId, objectInstanceId),
});
// Always fetch the parsed projection: the Parsed tab needs it, and the
// conditional "EE Parsed" tab derives from it (CMS-signed objects only).
const projectionQuery = useQuery({
queryKey: ["object-parsed", runId, objectInstanceId],
queryFn: () => getObjectProjection(runId, objectInstanceId),
enabled: tab === "parsed",
staleTime: 5 * 60_000,
});
@@ -251,6 +253,7 @@ export default function ObjectDetailPage() {
}
const object = objectQuery.data;
const eeProjection = eeCertificateProjection(projectionQuery.data ?? null);
const setTab = (next: string) => {
const params = new URLSearchParams(searchParams);
@@ -351,6 +354,7 @@ export default function ObjectDetailPage() {
<Tabs
tabs={[
{ id: "parsed", label: "Parsed" },
...(eeProjection ? [{ id: "ee", label: "EE Parsed" }] : []),
{ id: "validation", label: "Validation" },
{ id: "chain", label: "Chain" },
]}
@@ -377,6 +381,17 @@ export default function ObjectDetailPage() {
)}
</div>
</TabPanel>
{eeProjection ? (
<TabPanel id="ee" active={tab}>
<div style={{ padding: 16 }}>
<CertificateView projection={eeProjection} />
</div>
</TabPanel>
) : tab === "ee" ? (
<div style={{ padding: 16 }}>
<Notice kind="info">This object has no embedded EE certificate in its CMS wrapper.</Notice>
</div>
) : null}
<TabPanel id="validation" active={tab}>
<ValidationTab runId={runId} objectInstanceId={objectInstanceId} />
</TabPanel>

View File

@@ -15,6 +15,7 @@ import {
import { Clock, GitBranch, Timer } from "lucide-react";
import {
getStatsObjectTypes,
getStatsOverview,
getStatsReasons,
getStatsValidation,
listRepos,
@@ -35,6 +36,7 @@ import {
objectTypeLabel,
truncateMiddle,
} from "../lib/format";
import { clusterReasons } from "../lib/reasons";
import { withRunParam } from "../lib/run";
import { useRun, runErrorTitle } from "../lib/useRun";
@@ -61,7 +63,7 @@ export default function OverviewPage() {
const { runId, runQuery } = useRun();
const navigate = useNavigate();
const [validationQuery, typesQuery, reasonsQuery, reposQuery] = useQueries({
const [validationQuery, typesQuery, reasonsQuery, reposQuery, overviewQuery] = useQueries({
queries: [
{
queryKey: ["stats", runId, "validation"],
@@ -80,7 +82,12 @@ export default function OverviewPage() {
},
{
queryKey: ["repos", runId, "top"],
queryFn: () => listRepos(runId, { limit: 8 }),
queryFn: () => listRepos(runId, { limit: 8, sort: "objects", order: "desc" }),
staleTime: 60_000,
},
{
queryKey: ["stats", runId, "overview"],
queryFn: () => getStatsOverview(runId),
staleTime: 60_000,
},
],
@@ -109,13 +116,10 @@ export default function OverviewPage() {
.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 reasonRows = useMemo(
() => clusterReasons(reasonsQuery.data ?? {}),
[reasonsQuery.data],
);
const maxReason = reasonRows[0]?.count ?? 0;
@@ -224,6 +228,11 @@ export default function OverviewPage() {
value={formatInt(counts?.publicationPoints)}
to={withRunParam("/publication-points", runId)}
/>
<KpiCard
label="Repositories"
value={formatInt(overviewQuery.data?.repos)}
to={withRunParam("/repositories", runId)}
/>
<KpiCard
label="Rejected"
value={formatInt(counts?.rejectedObjects)}
@@ -304,7 +313,7 @@ export default function OverviewPage() {
<YAxis
type="category"
dataKey="name"
width={104}
width={150}
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "var(--text-2)" }}
@@ -325,7 +334,7 @@ export default function OverviewPage() {
<div className="overview-bottom">
<Panel
title="Top repositories"
subtitle="First repositories by index order"
subtitle="Repositories with the most objects"
tools={
<Link className="btn small" to={withRunParam("/repositories", runId)}>
View all
@@ -353,7 +362,7 @@ export default function OverviewPage() {
<Panel
title="Top reject reasons"
subtitle="Click a reason to inspect matching objects"
subtitle="Grouped by reason prefix — click to inspect matching objects"
tools={
<Link className="btn small" to={withRunParam("/validation", runId)}>
Validation
@@ -370,12 +379,17 @@ export default function OverviewPage() {
<div className="reason-list">
{reasonRows.map((row) => (
<Link
key={row.reason}
key={row.key}
className="reason-row"
to={withRunParam(`/validation?reason=${encodeURIComponent(row.reason)}`, runId)}
title={row.reason}
to={withRunParam(`/validation?reason=${encodeURIComponent(row.key)}`, runId)}
title={row.key}
>
<span className="reason-text">{row.reason}</span>
<span className="reason-text">
{row.key}
{row.variants > 1 ? (
<span className="text-faint text-small"> ×{row.variants}</span>
) : null}
</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}%` }} />