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

394 lines
14 KiB
TypeScript

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, 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] = 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={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="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={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>
);
}