Files
rpki/ui/rpki-explorer/src/pages/OverviewPage.tsx

408 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
getStatsOverview,
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 { clusterReasons } from "../lib/reasons";
import { withRunParam } from "../lib/run";
import { useRun, runErrorTitle } 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, overviewQuery] = 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, sort: "objects", order: "desc" }),
staleTime: 60_000,
},
{
queryKey: ["stats", runId, "overview"],
queryFn: () => getStatsOverview(runId),
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(
() => clusterReasons(reasonsQuery.data ?? {}),
[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={runErrorTitle(runId, runQuery.error)}
/>
</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),
},
{
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="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="Repositories"
value={formatInt(overviewQuery.data?.repos)}
to={withRunParam("/repositories", 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"
to={withRunParam("/validation", runId)}
/>
</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 initialDimension={{ width: 400, height: 240 }}>
<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 initialDimension={{ width: 400, height: 240 }}>
<BarChart data={typeData} layout="vertical" margin={{ left: 12, right: 24 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={150}
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="Repositories with the most objects"
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="Grouped by reason prefix — click 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.key}
className="reason-row"
to={withRunParam(`/validation?reason=${encodeURIComponent(row.key)}`, runId)}
title={row.key}
>
<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}%` }} />
</span>
</Link>
))}
</div>
)}
</Panel>
</div>
</>
)}
</div>
);
}