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