261 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|