149 lines
5.0 KiB
TypeScript
149 lines
5.0 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { listRepos } from "../api/service";
|
|
import type { RepositoryRecord } from "../api/schemas";
|
|
import { CursorPagerControls } from "../components/CursorPagerControls";
|
|
import { DataTable, type Column } from "../components/DataTable";
|
|
import { PageHeader } from "../components/PageHeader";
|
|
import { Panel } from "../components/Panel";
|
|
import { StatusPill } from "../components/StatusPill";
|
|
import { useCursorPager } from "../lib/cursor";
|
|
import { formatBytes, formatDurationMs, formatInt, truncateMiddle } from "../lib/format";
|
|
import { withRunParam } from "../lib/run";
|
|
import { useRun } from "../lib/useRun";
|
|
|
|
export default function RepositoriesPage() {
|
|
const { runId, runQuery } = useRun();
|
|
const navigate = useNavigate();
|
|
const pager = useCursorPager(runId);
|
|
const [needle, setNeedle] = useState("");
|
|
|
|
const reposQuery = useQuery({
|
|
queryKey: ["repos", runId, pager.cursor],
|
|
queryFn: () => listRepos(runId, { limit: 50, cursor: pager.cursor }),
|
|
placeholderData: (prev) => prev,
|
|
});
|
|
|
|
const rows = useMemo(() => {
|
|
const items = reposQuery.data?.items ?? [];
|
|
const q = needle.trim().toLowerCase();
|
|
if (!q) return items;
|
|
return items.filter(
|
|
(repo) =>
|
|
repo.host.toLowerCase().includes(q) || repo.uri.toLowerCase().includes(q),
|
|
);
|
|
}, [reposQuery.data, needle]);
|
|
|
|
const columns: 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: 320 }} title={repo.uri}>
|
|
{truncateMiddle(repo.uri, 64)}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "transport",
|
|
header: "Transport",
|
|
render: (repo) => <span className="chip">{repo.transport ?? "unknown"}</span>,
|
|
},
|
|
{ key: "pps", header: "PPs", numeric: true, render: (repo) => formatInt(repo.publicationPoints) },
|
|
{ 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: "bytes", header: "Downloaded", numeric: true, render: (repo) => formatBytes(repo.downloadBytes) },
|
|
{
|
|
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="Repositories"
|
|
subtitle={
|
|
runQuery.data
|
|
? `${formatInt(runQuery.data.counts?.objects)} objects across all repositories in ${runQuery.data.runId}`
|
|
: undefined
|
|
}
|
|
/>
|
|
<Panel
|
|
title="All repositories"
|
|
tools={
|
|
<div className="filter-field">
|
|
<label htmlFor="repo-filter" className="sr-only">
|
|
Filter current page
|
|
</label>
|
|
<input
|
|
id="repo-filter"
|
|
type="search"
|
|
value={needle}
|
|
onChange={(e) => setNeedle(e.target.value)}
|
|
placeholder="Filter current page (host/URI)…"
|
|
style={{ minWidth: 220 }}
|
|
/>
|
|
</div>
|
|
}
|
|
flush
|
|
>
|
|
<DataTable
|
|
columns={columns}
|
|
rows={rows}
|
|
rowKey={(repo) => repo.repoId}
|
|
loading={reposQuery.isPending}
|
|
error={reposQuery.isError ? reposQuery.error : undefined}
|
|
onRetry={() => reposQuery.refetch()}
|
|
emptyTitle={needle.trim() ? "No matches on this page — clear the filter" : "No repositories indexed"}
|
|
emptyHint={
|
|
needle.trim()
|
|
? "The filter only narrows the rows of the current page."
|
|
: "The query service has not indexed any run yet."
|
|
}
|
|
caption="Repositories"
|
|
onRowClick={(repo) =>
|
|
navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId))
|
|
}
|
|
/>
|
|
<CursorPagerControls
|
|
page={pager.page}
|
|
canPrev={pager.canPrev}
|
|
hasNext={reposQuery.data?.nextCursor != null}
|
|
loading={reposQuery.isFetching}
|
|
onPrev={pager.goPrev}
|
|
onNext={() => pager.goNext(reposQuery.data?.nextCursor ?? null)}
|
|
itemCount={rows.length}
|
|
/>
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|