20260718 重构 RPKI Explorer 查询界面
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user