rpki/ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx

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));
}