408 lines
14 KiB
TypeScript
408 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,
|
||
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>
|
||
);
|
||
}
|