468 lines
19 KiB
TypeScript
468 lines
19 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { ChevronRight, Database, FileCode2, GitBranch, Loader2, PanelLeftClose, PanelRightClose } from "lucide-react";
|
|
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
|
import { useMemo } from "react";
|
|
import { CopyableValue } from "../../components/common/CopyableValue";
|
|
import { CursorPager } from "../../components/common/CursorPager";
|
|
import { advancePage, previousPage, type PageState } from "../../components/common/cursorPaging";
|
|
import { createEmptyPageState, type RepositoryBrowserState } from "./repositoryBrowserState";
|
|
import {
|
|
getLatestRun,
|
|
listObjectsForPublicationPoint,
|
|
listPublicationPointsForRepo,
|
|
listRepos,
|
|
type ObjectInstanceRecord,
|
|
type PublicationPointRecord,
|
|
type RepositoryRecord
|
|
} from "../../api/queryService";
|
|
import { normalizeApiError } from "../../api/client";
|
|
|
|
const REPO_PAGE_SIZE = 50;
|
|
const PP_PAGE_SIZE = 50;
|
|
const OBJECT_PAGE_SIZE = 50;
|
|
|
|
interface RepositoriesPageProps {
|
|
browserState: RepositoryBrowserState;
|
|
onBrowserStateChange: Dispatch<SetStateAction<RepositoryBrowserState>>;
|
|
onOpenObject?: (objectInstanceId: string) => void;
|
|
}
|
|
|
|
export function RepositoriesPage({ browserState, onBrowserStateChange, onOpenObject }: RepositoriesPageProps) {
|
|
const {
|
|
selectedRepoId,
|
|
selectedPpId,
|
|
repoFilter,
|
|
ppFilter,
|
|
objectFilter,
|
|
repoPage,
|
|
ppPage,
|
|
objectPage,
|
|
isRepoPanelCollapsed,
|
|
isPpPanelCollapsed
|
|
} = browserState;
|
|
const setRepoFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, repoFilter: value }));
|
|
const setPpFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, ppFilter: value }));
|
|
const setObjectFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, objectFilter: value }));
|
|
const setRepoPage = (updater: SetStateAction<PageState>) => {
|
|
onBrowserStateChange((state) => ({
|
|
...state,
|
|
repoPage: typeof updater === "function" ? updater(state.repoPage) : updater
|
|
}));
|
|
};
|
|
const setPpPage = (updater: SetStateAction<PageState>) => {
|
|
onBrowserStateChange((state) => ({
|
|
...state,
|
|
ppPage: typeof updater === "function" ? updater(state.ppPage) : updater
|
|
}));
|
|
};
|
|
const setObjectPage = (updater: SetStateAction<PageState>) => {
|
|
onBrowserStateChange((state) => ({
|
|
...state,
|
|
objectPage: typeof updater === "function" ? updater(state.objectPage) : updater
|
|
}));
|
|
};
|
|
const latestRunQuery = useQuery({
|
|
queryKey: ["repositories-latest-run"],
|
|
queryFn: getLatestRun
|
|
});
|
|
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
|
const reposQuery = useQuery({
|
|
queryKey: ["repositories", runId, repoPage.cursor],
|
|
queryFn: () => listRepos(runId, REPO_PAGE_SIZE, repoPage.cursor),
|
|
enabled: Boolean(latestRunQuery.data?.data.runId)
|
|
});
|
|
|
|
const repos = useMemo(() => reposQuery.data?.data ?? [], [reposQuery.data?.data]);
|
|
const filteredRepos = useMemo(() => filterRepositories(repos, repoFilter), [repoFilter, repos]);
|
|
const selectedRepo = selectedRepoId ? repos.find((repo) => repo.repoId === selectedRepoId) ?? null : null;
|
|
const ppsQuery = useQuery({
|
|
queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? "", ppPage.cursor],
|
|
queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", PP_PAGE_SIZE, ppPage.cursor),
|
|
enabled: Boolean(selectedRepo?.repoId)
|
|
});
|
|
|
|
const publicationPoints = useMemo(() => ppsQuery.data?.data ?? [], [ppsQuery.data?.data]);
|
|
const filteredPublicationPoints = useMemo(() => filterPublicationPoints(publicationPoints, ppFilter), [ppFilter, publicationPoints]);
|
|
const selectedPp = selectedPpId
|
|
? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null
|
|
: null;
|
|
const objectsQuery = useQuery({
|
|
queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? "", objectPage.cursor],
|
|
queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", OBJECT_PAGE_SIZE, objectPage.cursor),
|
|
enabled: Boolean(selectedPp?.ppId)
|
|
});
|
|
const objects = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]);
|
|
const filteredObjects = useMemo(() => filterObjects(objects, objectFilter), [objectFilter, objects]);
|
|
const repoTotal = reposQuery.data?.page?.nextCursor ? undefined : repoPage.previous.length * REPO_PAGE_SIZE + repos.length;
|
|
const ppTotal = selectedRepo?.publicationPoints;
|
|
const objectTotal = selectedPp?.objects;
|
|
|
|
const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error;
|
|
|
|
return (
|
|
<section className="page-stack repositories-page" aria-labelledby="repositories-heading">
|
|
<div className="hero-dashboard compact-hero">
|
|
<div>
|
|
<p className="eyebrow">Repository browser · live query service</p>
|
|
<h2 id="repositories-heading">Repository / publication point / object browser</h2>
|
|
<p>Browse repo trees first, then expand publication points and object rows only on demand.</p>
|
|
</div>
|
|
<div className="hero-status">
|
|
<Database size={20} />
|
|
<div>
|
|
<strong>{latestRunQuery.data?.data.runId ?? "Loading run"}</strong>
|
|
<span>{repos.length ? `${repos.length} repositories loaded` : "waiting for query service"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="alert-banner" role="status">
|
|
<strong>Repository API unavailable</strong>
|
|
<span>{normalizeApiError(error).message}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className={`repo-browser-grid ${isRepoPanelCollapsed ? "repo-panel-collapsed" : ""} ${isPpPanelCollapsed ? "pp-panel-collapsed" : ""}`}>
|
|
<section className="panel list-panel collapsible-list-panel repo-list-panel" aria-label="Repositories">
|
|
<PanelTitle
|
|
icon={<Database size={18} />}
|
|
isCollapsed={isRepoPanelCollapsed}
|
|
label="Repositories"
|
|
meta={reposQuery.isFetching ? "loading" : rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
|
|
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isRepoPanelCollapsed: !state.isRepoPanelCollapsed }))}
|
|
toggleLabel={isRepoPanelCollapsed ? "Expand repositories column" : "Collapse repositories column"}
|
|
/>
|
|
{isRepoPanelCollapsed ? (
|
|
<CollapsedPanelSummary icon={<Database size={20} />} label="Repositories" value={repoTotal ?? repos.length} />
|
|
) : (
|
|
<>
|
|
<CurrentPageFilter
|
|
label="Filter repositories on current page"
|
|
onChange={setRepoFilter}
|
|
placeholder="Filter host or URI..."
|
|
value={repoFilter}
|
|
/>
|
|
<div className="stack-list">
|
|
{filteredRepos.map((repo) => (
|
|
<article className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`} key={repo.repoId}>
|
|
<button
|
|
className="stack-row-select"
|
|
onClick={() => {
|
|
onBrowserStateChange((state) => ({
|
|
...state,
|
|
selectedRepoId: repo.repoId,
|
|
selectedPpId: null,
|
|
ppFilter: "",
|
|
objectFilter: "",
|
|
ppPage: createEmptyPageState(),
|
|
objectPage: createEmptyPageState()
|
|
}));
|
|
}}
|
|
type="button"
|
|
>
|
|
<span>
|
|
<strong>{repo.host}</strong>
|
|
<small>{repo.transport.toUpperCase()} · {repo.publicationPoints.toLocaleString()} publication points</small>
|
|
</span>
|
|
<em>{repo.objects.toLocaleString()}</em>
|
|
<ChevronRight size={16} />
|
|
</button>
|
|
<CopyableValue className="stack-row-copy" label="repository URI" value={repo.uri} />
|
|
</article>
|
|
))}
|
|
</div>
|
|
<CursorPager
|
|
isFetching={reposQuery.isFetching}
|
|
pageNumber={repoPage.previous.length + 1}
|
|
nextCursor={reposQuery.data?.page?.nextCursor ?? null}
|
|
onNext={() => setRepoPage((page) => advancePage(page, reposQuery.data?.page?.nextCursor ?? null))}
|
|
onPrevious={() => setRepoPage(previousPage)}
|
|
previousCount={repoPage.previous.length}
|
|
rangeLabel={rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
|
|
/>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<section className="panel list-panel collapsible-list-panel pp-list-panel" aria-label="Publication points">
|
|
<PanelTitle
|
|
icon={<GitBranch size={18} />}
|
|
isCollapsed={isPpPanelCollapsed}
|
|
label="Publication Points"
|
|
meta={ppsQuery.isFetching ? "loading" : selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : "select repo"}
|
|
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isPpPanelCollapsed: !state.isPpPanelCollapsed }))}
|
|
toggleLabel={isPpPanelCollapsed ? "Expand publication points column" : "Collapse publication points column"}
|
|
/>
|
|
{isPpPanelCollapsed ? (
|
|
<CollapsedPanelSummary icon={<GitBranch size={20} />} label="Publication Points" value={ppTotal ?? publicationPoints.length} />
|
|
) : (
|
|
<>
|
|
{!selectedRepo ? (
|
|
<div className="empty-state">Select a repository to load its publication points.</div>
|
|
) : null}
|
|
<CurrentPageFilter
|
|
disabled={!selectedRepo}
|
|
label="Filter publication points on current page"
|
|
onChange={setPpFilter}
|
|
placeholder="Filter manifest, rsync, source..."
|
|
value={ppFilter}
|
|
/>
|
|
{ppsQuery.isFetching ? <LoadingLine /> : null}
|
|
<div className="stack-list">
|
|
{filteredPublicationPoints.map((pp) => (
|
|
<article className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`} key={pp.ppId}>
|
|
<button
|
|
className="stack-row-select"
|
|
onClick={() => {
|
|
onBrowserStateChange((state) => ({
|
|
...state,
|
|
selectedPpId: pp.ppId,
|
|
objectFilter: "",
|
|
objectPage: createEmptyPageState()
|
|
}));
|
|
}}
|
|
type="button"
|
|
>
|
|
<span>
|
|
<strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong>
|
|
<small>{pp.repoSyncSource ?? pp.source ?? "unknown"} · {pp.ppId}</small>
|
|
</span>
|
|
<em>{pp.objects.toLocaleString()}</em>
|
|
<StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} />
|
|
</button>
|
|
<CopyableValue className="stack-row-copy" label="publication point URI" value={pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? pp.ppId} />
|
|
{pp.manifestRsyncUri ? <CopyableValue className="stack-row-copy secondary" label="manifest URI" value={pp.manifestRsyncUri} /> : null}
|
|
</article>
|
|
))}
|
|
</div>
|
|
<CursorPager
|
|
isFetching={ppsQuery.isFetching}
|
|
nextCursor={ppsQuery.data?.page?.nextCursor ?? null}
|
|
onNext={() => setPpPage((page) => advancePage(page, ppsQuery.data?.page?.nextCursor ?? null))}
|
|
onPrevious={() => setPpPage(previousPage)}
|
|
pageNumber={ppPage.previous.length + 1}
|
|
previousCount={ppPage.previous.length}
|
|
rangeLabel={selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : undefined}
|
|
/>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<section className="panel table-panel objects-live-panel" aria-label="Objects for publication point">
|
|
<PanelTitle
|
|
icon={<FileCode2 size={18} />}
|
|
label="Objects"
|
|
meta={objectsQuery.isFetching ? "loading" : selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : "select PP"}
|
|
/>
|
|
{!selectedPp ? (
|
|
<div className="empty-state">Select a publication point to load its objects.</div>
|
|
) : null}
|
|
<CurrentPageFilter
|
|
disabled={!selectedPp}
|
|
label="Filter objects on current page"
|
|
onChange={setObjectFilter}
|
|
placeholder="Filter type, URI, hash, status..."
|
|
value={objectFilter}
|
|
/>
|
|
{objectsQuery.isFetching ? <LoadingLine /> : null}
|
|
{selectedPp && !objectsQuery.isFetching ? (
|
|
<ObjectTable objects={filteredObjects} onOpenObject={onOpenObject} />
|
|
) : null}
|
|
<CursorPager
|
|
isFetching={objectsQuery.isFetching}
|
|
nextCursor={objectsQuery.data?.page?.nextCursor ?? null}
|
|
onNext={() => setObjectPage((page) => advancePage(page, objectsQuery.data?.page?.nextCursor ?? null))}
|
|
onPrevious={() => setObjectPage(previousPage)}
|
|
pageNumber={objectPage.previous.length + 1}
|
|
previousCount={objectPage.previous.length}
|
|
rangeLabel={selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : undefined}
|
|
/>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function PanelTitle({
|
|
icon,
|
|
isCollapsed,
|
|
label,
|
|
meta,
|
|
onToggleCollapse,
|
|
toggleLabel
|
|
}: {
|
|
icon: ReactNode;
|
|
isCollapsed?: boolean;
|
|
label: string;
|
|
meta: string;
|
|
onToggleCollapse?: () => void;
|
|
toggleLabel?: string;
|
|
}) {
|
|
return (
|
|
<div className="panel-heading compact-heading">
|
|
<div>
|
|
<p className="eyebrow">{label}</p>
|
|
<h3>{label}</h3>
|
|
</div>
|
|
<div className="panel-heading-actions">
|
|
<span className="panel-meta">
|
|
{icon}
|
|
{meta}
|
|
</span>
|
|
{onToggleCollapse ? (
|
|
<button aria-expanded={!isCollapsed} aria-label={toggleLabel} className="panel-collapse-button" onClick={onToggleCollapse} title={toggleLabel} type="button">
|
|
{isCollapsed ? <PanelRightClose aria-hidden="true" size={16} /> : <PanelLeftClose aria-hidden="true" size={16} />}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CollapsedPanelSummary({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
|
|
return (
|
|
<div className="collapsed-panel-summary">
|
|
{icon}
|
|
<strong>{label}</strong>
|
|
<span>{value.toLocaleString()}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CurrentPageFilter({
|
|
disabled,
|
|
label,
|
|
onChange,
|
|
placeholder,
|
|
value
|
|
}: {
|
|
disabled?: boolean;
|
|
label: string;
|
|
onChange: (value: string) => void;
|
|
placeholder: string;
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<label className="current-page-filter">
|
|
<span>{label}</span>
|
|
<input
|
|
disabled={disabled}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
placeholder={placeholder}
|
|
value={value}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[]; onOpenObject?: (objectInstanceId: string) => void }) {
|
|
if (objects.length === 0) {
|
|
return <div className="empty-state">No objects returned for this publication point.</div>;
|
|
}
|
|
return (
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Type</th>
|
|
<th>URI</th>
|
|
<th>Hash</th>
|
|
<th>Source</th>
|
|
<th>Result</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{objects.map((object) => (
|
|
<tr key={object.objectInstanceId}>
|
|
<td><span className="object-type">{object.objectType}</span></td>
|
|
<td className="uri-cell"><CopyableValue label="object URI" value={object.uri} /></td>
|
|
<td><CopyableValue label="object SHA256" value={object.sha256} displayValue={shortHash(object.sha256, 12)} /></td>
|
|
<td>{object.sourceSection}</td>
|
|
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
|
|
<td>
|
|
<button className="table-link-button" onClick={() => onOpenObject?.(object.objectInstanceId)} type="button">
|
|
Open
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
function StatusPill({ state }: { state: string }) {
|
|
const normalized = state.toLowerCase();
|
|
const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error")
|
|
? "warning"
|
|
: normalized.includes("cache") || normalized.includes("fallback")
|
|
? "fallback"
|
|
: "healthy";
|
|
return <span className={`status-badge ${className}`}>{state}</span>;
|
|
}
|
|
|
|
function LoadingLine() {
|
|
return (
|
|
<div className="loading-line">
|
|
<Loader2 size={16} />
|
|
Loading selected branch...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function shortName(uri: string) {
|
|
return uri.split("/").filter(Boolean).at(-1) ?? uri;
|
|
}
|
|
|
|
function shortHash(value: string, prefixLength: number) {
|
|
return `${value.slice(0, prefixLength)}...`;
|
|
}
|
|
|
|
function rangeLabel(pageNumber: number, pageSize: number, pageRowCount: number, total: number | undefined, filter: string, visibleRowCount = pageRowCount) {
|
|
const start = pageRowCount === 0 ? 0 : (pageNumber - 1) * pageSize + 1;
|
|
const end = pageRowCount === 0 ? 0 : start + pageRowCount - 1;
|
|
const totalText = total === undefined ? "unknown" : total.toLocaleString();
|
|
const filterText = filter.trim() ? ` · ${visibleRowCount.toLocaleString()}/${pageRowCount.toLocaleString()} matched on page` : "";
|
|
return `${start.toLocaleString()}-${end.toLocaleString()}/${totalText} shown${filterText}`;
|
|
}
|
|
|
|
function matchesNeedle(values: Array<string | number | null | undefined>, needle: string) {
|
|
const normalized = needle.trim().toLowerCase();
|
|
if (!normalized) {
|
|
return true;
|
|
}
|
|
return values.some((value) => String(value ?? "").toLowerCase().includes(normalized));
|
|
}
|
|
|
|
function filterRepositories(repos: RepositoryRecord[], filter: string) {
|
|
return repos.filter((repo) => matchesNeedle([repo.host, repo.uri, repo.transport, repo.objects, repo.rejectedObjects], filter));
|
|
}
|
|
|
|
function filterPublicationPoints(publicationPoints: PublicationPointRecord[], filter: string) {
|
|
return publicationPoints.filter((pp) => matchesNeedle([
|
|
pp.ppId,
|
|
pp.manifestRsyncUri,
|
|
pp.publicationPointRsyncUri,
|
|
pp.rsyncBaseUri,
|
|
pp.rrdpNotificationUri,
|
|
pp.source,
|
|
pp.repoSyncSource,
|
|
pp.repoTerminalState,
|
|
pp.objects,
|
|
pp.rejectedObjects
|
|
], filter));
|
|
}
|
|
|
|
function filterObjects(objects: ObjectInstanceRecord[], filter: string) {
|
|
return objects.filter((object) => matchesNeedle([
|
|
object.objectType,
|
|
object.uri,
|
|
object.sha256,
|
|
object.sourceSection,
|
|
object.result,
|
|
object.rejectReason,
|
|
object.rejected ? "rejected" : "accepted"
|
|
], filter));
|
|
}
|