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

261 lines
9.2 KiB
TypeScript

import { useMemo } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { PackageOpen, RefreshCw } from "lucide-react";
import {
getRepo,
getRepoStats,
listRepoObjects,
listRepoPublicationPoints,
} from "../api/service";
import type { PublicationPointRecord } from "../api/schemas";
import { CopyableValue } from "../components/CopyableValue";
import { CursorPagerControls } from "../components/CursorPagerControls";
import { DataTable, type Column } from "../components/DataTable";
import { KpiCard } from "../components/KpiCard";
import {
objectFiltersFromParams,
objectFiltersToParams,
ObjectsTable,
type ObjectFilterValues,
} from "../components/ObjectsTable";
import { PageHeader } from "../components/PageHeader";
import { Panel } from "../components/Panel";
import { StatusPill } from "../components/StatusPill";
import { TabPanel, Tabs } from "../components/Tabs";
import { WorkflowStatus } from "../components/WorkflowStatus";
import { ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
import { useCursorPager } from "../lib/cursor";
import { formatDurationMs, formatInt, formatUtc, truncateMiddle } from "../lib/format";
import { withRunParam } from "../lib/run";
import { useExportJob } from "../lib/useExportJob";
import { useRun } from "../lib/useRun";
function RepoPpsTable({ runId, repoId }: { runId: string; repoId: string }) {
const navigate = useNavigate();
const pager = useCursorPager(`${runId}:${repoId}`);
const ppsQuery = useQuery({
queryKey: ["repo-pps", runId, repoId, pager.cursor],
queryFn: () => listRepoPublicationPoints(runId, repoId, { limit: 50, cursor: pager.cursor }),
placeholderData: (prev) => prev,
});
const columns: Column<PublicationPointRecord>[] = [
{
key: "manifest",
header: "Manifest URI",
render: (pp) => (
<span className="mono text-small ellipsis" style={{ display: "block", maxWidth: 420 }} title={pp.manifestRsyncUri ?? undefined}>
{pp.manifestRsyncUri ? truncateMiddle(pp.manifestRsyncUri, 72) : pp.ppId}
</span>
),
},
{
key: "source",
header: "Sync source",
render: (pp) => <span className="chip">{pp.repoSyncSource ?? pp.source ?? "—"}</span>,
},
{
key: "state",
header: "Terminal state",
render: (pp) => <StatusPill status={pp.repoTerminalState} />,
},
{ key: "objects", header: "Objects", numeric: true, render: (pp) => formatInt(pp.objects) },
{
key: "rejected",
header: "Rejected",
numeric: true,
render: (pp) =>
pp.rejectedObjects ? (
<span style={{ color: "var(--red-700)", fontWeight: 600 }}>{formatInt(pp.rejectedObjects)}</span>
) : (
"0"
),
},
{
key: "duration",
header: "Sync time",
numeric: true,
render: (pp) => formatDurationMs(pp.repoSyncDurationMs),
},
];
return (
<>
<DataTable
columns={columns}
rows={ppsQuery.data?.items ?? []}
rowKey={(pp) => pp.ppId}
loading={ppsQuery.isPending}
error={ppsQuery.isError ? ppsQuery.error : undefined}
onRetry={() => ppsQuery.refetch()}
emptyTitle="No publication points"
caption="Publication points in this repository"
onRowClick={(pp) =>
navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId))
}
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={ppsQuery.data?.nextCursor != null}
loading={ppsQuery.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(ppsQuery.data?.nextCursor ?? null)}
itemCount={ppsQuery.data?.items.length}
/>
</>
);
}
export default function RepositoryDetailPage() {
const { repoId = "" } = useParams();
const { runId, runQuery } = useRun();
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get("tab") ?? "pps";
// Object filters live in the URL so they survive reloads and stay shareable.
const objectFilters = useMemo(() => objectFiltersFromParams(searchParams), [searchParams]);
const setObjectFilters = (next: ObjectFilterValues) => {
setSearchParams(objectFiltersToParams(next, searchParams), { preventScrollReset: true });
};
const repoQuery = useQuery({
queryKey: ["repo", runId, repoId],
queryFn: () => getRepo(runId, repoId),
});
const statsQuery = useQuery({
queryKey: ["repo-stats", runId, repoId],
queryFn: () => getRepoStats(runId, repoId),
});
const exportJob = useExportJob(runId, { scope: "repo", repoId });
if (repoQuery.isError) {
return (
<div className="page">
<PageHeader title="Repository" />
<ErrorBlock error={repoQuery.error} onRetry={() => repoQuery.refetch()} title="Failed to load repository" />
</div>
);
}
if (repoQuery.isPending) {
return (
<div className="page">
<PageHeader title="Repository" />
<LoadingBlock />
</div>
);
}
const repo = repoQuery.data;
const stats = statsQuery.data;
const setTab = (next: string) => {
const params = new URLSearchParams(searchParams);
params.set("tab", next);
setSearchParams(params, { preventScrollReset: true });
};
return (
<div className="page">
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link to={withRunParam("/repositories", runId)}>Repositories</Link>
<span aria-hidden="true">/</span>
<span>{repo.host}</span>
</nav>
<PageHeader
title={repo.host}
subtitle={<CopyableValue value={repo.uri} max={96} label="repository URI" />}
actions={
<>
<StatusPill status={runQuery.data?.indexStatus} label={`run ${runQuery.data?.runId ?? runId}`} />
<button
type="button"
className="btn"
onClick={() => exportJob.startMutation.mutate()}
disabled={exportJob.running}
>
{exportJob.startMutation.isPending ? (
<RefreshCw size={13} aria-hidden="true" />
) : (
<PackageOpen size={13} aria-hidden="true" />
)}
Export
</button>
</>
}
/>
{exportJob.startMutation.isError ? (
<Notice kind="error">Export failed to start: {exportJob.startMutation.error.message}</Notice>
) : null}
{exportJob.jobQuery.data ? <WorkflowStatus job={exportJob.jobQuery.data} runId={runId} /> : null}
<div className="kpi-grid">
<KpiCard label="Publication points" value={formatInt(repo.publicationPoints)} />
<KpiCard label="Objects" value={formatInt(repo.objects)} />
<KpiCard label="Rejected" value={formatInt(repo.rejectedObjects)} tone="red" />
<KpiCard label="Sync time" value={formatDurationMs(repo.syncDurationMsTotal)} />
</div>
<Panel title="Repository details">
<dl className="meta-grid">
<dt>Repository ID</dt>
<dd><CopyableValue value={repo.repoId} max={48} label="repository id" /></dd>
<dt>URI</dt>
<dd><CopyableValue value={repo.uri} max={96} label="repository URI" /></dd>
<dt>Host</dt>
<dd>{repo.host}</dd>
<dt>Transport</dt>
<dd><span className="chip">{repo.transport ?? "unknown"}</span></dd>
<dt>Sync phases</dt>
<dd>
<span className="chip-list">
{Object.entries(stats?.phases ?? repo.phases ?? {}).map(([phase, count]) => (
<span className="chip" key={phase}>{phase} {formatInt(count)}</span>
))}
{Object.keys(stats?.phases ?? repo.phases ?? {}).length === 0 && <span className="text-faint"></span>}
</span>
</dd>
<dt>Terminal states</dt>
<dd>
<span className="chip-list">
{Object.entries(stats?.terminalStates ?? repo.terminalStates ?? {}).map(([state, count]) => (
<StatusPill key={state} status={state} label={`${state} ${formatInt(count)}`} />
))}
{Object.keys(stats?.terminalStates ?? repo.terminalStates ?? {}).length === 0 && <span className="text-faint"></span>}
</span>
</dd>
<dt>Run validated</dt>
<dd>{formatUtc(runQuery.data?.validationTime)}</dd>
</dl>
</Panel>
<Panel flush>
<Tabs
tabs={[
{ id: "pps", label: "Publication points" },
{ id: "objects", label: "Objects" },
]}
active={tab}
onChange={setTab}
ariaLabel="Repository sections"
/>
<TabPanel id="pps" active={tab}>
<RepoPpsTable runId={runId} repoId={repoId} />
</TabPanel>
<TabPanel id="objects" active={tab}>
<ObjectsTable
runId={runId}
queryKeyScope={`repo:${repoId}`}
fetcher={(filters) => listRepoObjects(runId, repoId, filters)}
filters={objectFilters}
onFiltersChange={setObjectFilters}
/>
</TabPanel>
</Panel>
</div>
);
}