20260623 optimize rpki explorer ui interactions

This commit is contained in:
yuyr 2026-06-23 15:47:50 +08:00
parent 4f41fbe04e
commit 574e40a4d4
16 changed files with 1483 additions and 297 deletions

View File

@ -1,9 +1,13 @@
import { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } from "lucide-react"; import { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Shell } from "../components/layout/Shell"; import { Shell } from "../components/layout/Shell";
import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage"; import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage";
import { OverviewPage } from "../features/overview/OverviewPage"; import { OverviewPage } from "../features/overview/OverviewPage";
import { RepositoriesPage } from "../features/repositories/RepositoriesPage"; import { RepositoriesPage } from "../features/repositories/RepositoriesPage";
import { createRepositoryBrowserState } from "../features/repositories/repositoryBrowserState";
type ViewId = "overview" | "repositories" | "publication-points" | "objects" | "validation" | "exports" | "runs" | "api";
const navigationItems = [ const navigationItems = [
{ id: "overview", label: "Overview", icon: Home }, { id: "overview", label: "Overview", icon: Home },
@ -14,22 +18,86 @@ const navigationItems = [
{ id: "exports", label: "Exports", icon: PackageOpen }, { id: "exports", label: "Exports", icon: PackageOpen },
{ id: "runs", label: "Runs", icon: Activity }, { id: "runs", label: "Runs", icon: Activity },
{ id: "api", label: "API", icon: FileSearch } { id: "api", label: "API", icon: FileSearch }
]; ] satisfies Array<{ id: ViewId; label: string; icon: LucideIcon }>;
const comingSoonCopy: Record<Exclude<ViewId, "overview" | "repositories" | "objects">, { title: string; description: string; alternative: string }> = {
"publication-points": {
title: "Publication Points",
description: "Dedicated publication point analytics are not implemented yet.",
alternative: "Use Repositories to drill down from repository to publication point and object rows."
},
validation: {
title: "Validation",
description: "A dedicated validation investigation workspace is planned but not available in this MVP.",
alternative: "Use Overview for reason summaries and Object Detail for per-object validation evidence."
},
exports: {
title: "Exports",
description: "A standalone export job browser is planned but not available yet.",
alternative: "Use Object Detail to trigger object or publication point export workflows."
},
runs: {
title: "Runs",
description: "Historical run navigation is not implemented in the current Explorer UI.",
alternative: "The current view always uses the latest indexed run from query service."
},
api: {
title: "API",
description: "The API reference page is planned but not implemented yet.",
alternative: "Use the project README and query service endpoints while the UI reference page is pending."
}
};
export function App() { export function App() {
const [activeView, setActiveView] = useState("overview"); const [activeView, setActiveView] = useState<ViewId>("overview");
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(null); const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(null);
const [repositoryBrowserState, setRepositoryBrowserState] = useState(createRepositoryBrowserState);
const openObject = (objectInstanceId: string) => { const openObject = (objectInstanceId: string) => {
setSelectedObjectInstanceId(objectInstanceId); setSelectedObjectInstanceId(objectInstanceId);
setActiveView("objects"); setActiveView("objects");
}; };
const renderActiveView = () => {
if (activeView === "objects") {
return <ObjectDetailPage initialObjectInstanceId={selectedObjectInstanceId} />;
}
if (activeView === "repositories") {
return (
<RepositoriesPage
browserState={repositoryBrowserState}
onBrowserStateChange={setRepositoryBrowserState}
onOpenObject={openObject}
/>
);
}
if (activeView === "overview") {
return <OverviewPage />;
}
return <ComingSoonPage {...comingSoonCopy[activeView]} />;
};
return ( return (
<Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}> <Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}>
{activeView === "objects" ? <ObjectDetailPage initialObjectInstanceId={selectedObjectInstanceId} /> : null} {renderActiveView()}
{activeView === "repositories" ? <RepositoriesPage onOpenObject={openObject} /> : null}
{activeView !== "objects" && activeView !== "repositories" ? <OverviewPage /> : null}
</Shell> </Shell>
); );
} }
function ComingSoonPage({ title, description, alternative }: { title: string; description: string; alternative: string }) {
return (
<section className="page-stack" aria-labelledby="coming-soon-heading">
<div className="hero-dashboard compact-hero coming-soon-panel">
<div>
<p className="eyebrow">Planned workspace</p>
<h2 id="coming-soon-heading">{title}</h2>
<p>{description}</p>
</div>
<div className="hero-status muted-status">
<strong>Coming soon</strong>
<span>{alternative}</span>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,88 @@
import { Check, Copy } from "lucide-react";
import { useEffect, useState } from "react";
interface CopyableValueProps {
className?: string;
displayValue?: string;
label: string;
value: string;
}
interface TooltipPosition {
left: number;
top: number;
}
export function CopyableValue({ className, displayValue, label, value }: CopyableValueProps) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const [tooltipPosition, setTooltipPosition] = useState<TooltipPosition | null>(null);
useEffect(() => {
if (copyState === "idle") {
return;
}
const timeout = window.setTimeout(() => setCopyState("idle"), 1600);
return () => window.clearTimeout(timeout);
}, [copyState]);
const showTooltip = (target: HTMLElement) => {
const rect = target.getBoundingClientRect();
setTooltipPosition({
left: Math.min(window.innerWidth - 24, Math.max(24, rect.left + rect.width / 2)),
top: rect.top > 90 ? rect.top - 10 : rect.bottom + 10
});
};
const copy = async () => {
try {
await copyText(value);
setCopyState("copied");
} catch {
setCopyState("failed");
}
};
return (
<span className={`copyable-value ${className ?? ""}`} data-copy-state={copyState} data-copyable-label={label}>
<span
className="copyable-value-text"
onBlur={() => setTooltipPosition(null)}
onFocus={(event) => showTooltip(event.currentTarget)}
onMouseEnter={(event) => showTooltip(event.currentTarget)}
onMouseLeave={() => setTooltipPosition(null)}
tabIndex={0}
title={value}
>
{displayValue ?? value}
</span>
<button aria-label={`Copy ${label}`} className="copy-icon-button" onClick={copy} title={`Copy ${label}`} type="button">
{copyState === "copied" ? <Check aria-hidden="true" size={13} /> : <Copy aria-hidden="true" size={13} />}
</button>
{tooltipPosition ? (
<span className="copyable-tooltip" role="tooltip" style={{ left: tooltipPosition.left, top: tooltipPosition.top }}>
{value}
</span>
) : null}
{copyState !== "idle" ? <span className="copy-state-text">{copyState === "copied" ? "Copied" : "Copy failed"}</span> : null}
</span>
);
}
async function copyText(value: string) {
if (navigator.clipboard) {
await navigator.clipboard.writeText(value);
return;
}
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
} finally {
document.body.removeChild(textArea);
}
}

View File

@ -0,0 +1,32 @@
interface CursorPagerProps {
label?: string;
isFetching: boolean;
nextCursor: string | null;
onNext: () => void;
onPrevious: () => void;
pageNumber: number;
previousCount: number;
rangeLabel?: string;
}
export function CursorPager({
label,
isFetching,
nextCursor,
onNext,
onPrevious,
pageNumber,
previousCount,
rangeLabel
}: CursorPagerProps) {
return (
<div className="cursor-pager" aria-label="Cursor pagination">
<button disabled={isFetching || previousCount === 0} onClick={onPrevious} type="button">Previous</button>
<span>
{label ?? `Page ${pageNumber}`}
{rangeLabel ? <small>{rangeLabel}</small> : null}
</span>
<button disabled={isFetching || !nextCursor} onClick={onNext} type="button">Next</button>
</div>
);
}

View File

@ -0,0 +1,24 @@
export interface PageState {
cursor: string | null;
previous: Array<string | null>;
}
export function advancePage(page: PageState, nextCursor: string | null): PageState {
if (!nextCursor) {
return page;
}
return {
cursor: nextCursor,
previous: [...page.previous, page.cursor]
};
}
export function previousPage(page: PageState): PageState {
if (page.previous.length === 0) {
return page;
}
return {
cursor: page.previous[page.previous.length - 1] ?? null,
previous: page.previous.slice(0, -1)
};
}

View File

@ -1,26 +1,29 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { BarChart3, ChevronDown, CircleHelp, Search, Sun } from "lucide-react"; import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } from "react";
export interface NavigationItem { export interface NavigationItem<Id extends string = string> {
id: string; id: Id;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
} }
interface ShellProps extends PropsWithChildren { interface ShellProps<Id extends string> extends PropsWithChildren {
activeView: string; activeView: Id;
navigationItems: NavigationItem[]; navigationItems: Array<NavigationItem<Id>>;
onNavigate: (id: string) => void; onNavigate: (id: Id) => void;
} }
export function Shell({ activeView, navigationItems, onNavigate, children }: ShellProps) { export function Shell<Id extends string>({ activeView, navigationItems, onNavigate, children }: ShellProps<Id>) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
return ( return (
<div className="app-shell"> <div className={`app-shell ${isSidebarCollapsed ? "sidebar-collapsed" : ""}`}>
<aside className="sidebar" aria-label="Primary navigation"> <aside className="sidebar" aria-label="Primary navigation">
<div className="brand"> <div className="brand">
<div className="brand-mark">R</div> <div className="brand-mark">R</div>
<div> <div className="brand-copy">
<h1 className="brand-title">RPKI Explorer</h1> <h1 className="brand-title">RPKI Explorer</h1>
<div className="brand-subtitle">Validation Intelligence</div> <div className="brand-subtitle">Validation Intelligence</div>
</div> </div>
@ -29,9 +32,11 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
{navigationItems.map((item) => ( {navigationItems.map((item) => (
<button <button
aria-current={activeView === item.id ? "page" : undefined} aria-current={activeView === item.id ? "page" : undefined}
aria-label={isSidebarCollapsed ? item.label : undefined}
className={`nav-item ${activeView === item.id ? "active" : ""}`} className={`nav-item ${activeView === item.id ? "active" : ""}`}
key={item.id} key={item.id}
onClick={() => onNavigate(item.id)} onClick={() => onNavigate(item.id)}
title={isSidebarCollapsed ? item.label : undefined}
type="button" type="button"
> >
<item.icon aria-hidden="true" size={18} /> <item.icon aria-hidden="true" size={18} />
@ -39,24 +44,31 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
</button> </button>
))} ))}
</nav> </nav>
<button
aria-expanded={!isSidebarCollapsed}
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
className="sidebar-toggle"
onClick={() => setIsSidebarCollapsed((collapsed) => !collapsed)}
title={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
type="button"
>
{isSidebarCollapsed ? <PanelLeftOpen aria-hidden="true" size={18} /> : <PanelLeftClose aria-hidden="true" size={18} />}
<span>{isSidebarCollapsed ? "Expand" : "Collapse"}</span>
</button>
</aside> </aside>
<main className="main-panel"> <main className="main-panel">
<header className="topbar"> <header className="topbar">
<div className="run-selector" aria-label="Current run"> <div className="run-selector readonly-run" aria-label="Current run">
<span>Run</span> <span>Run</span>
<strong>latest run</strong> <strong>latest indexed run</strong>
<ChevronDown aria-hidden="true" size={16} />
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<label className="search-box"> <label className="search-box">
<Search aria-hidden="true" size={18} /> <Search aria-hidden="true" size={18} />
<span className="sr-only">Search URI or hash</span> <span className="sr-only">Exact object URI search is not active yet</span>
<input placeholder="Search URI / hash / ASN / prefix…" /> <input disabled placeholder="Exact URI lookup planned in M2" />
</label> </label>
<div className="ready-pill"><span /> Ready</div> <div className="ready-pill"><span /> UI Ready</div>
<button aria-label="Help" className="icon-button" type="button"><CircleHelp size={19} /></button>
<button aria-label="Theme" className="icon-button" type="button"><Sun size={19} /></button>
<button aria-label="Analytics" className="icon-button" type="button"><BarChart3 size={19} /></button>
</div> </div>
</header> </header>
{children} {children}

View File

@ -2,6 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, Copy, Download, FileText, GitBranch, Loader2, Search, ShieldCheck } from "lucide-react"; import { Check, Copy, Download, FileText, GitBranch, Loader2, Search, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { normalizeApiError } from "../../api/client"; import { normalizeApiError } from "../../api/client";
import { CopyableValue } from "../../components/common/CopyableValue";
import { import {
createObjectSetExport, createObjectSetExport,
createPublicationPointExport, createPublicationPointExport,
@ -14,7 +15,6 @@ import {
getObjectParsed, getObjectParsed,
getObjectValidation, getObjectValidation,
listManifestFiles, listManifestFiles,
listObjectsForPublicationPoint,
listRevokedCertificates, listRevokedCertificates,
type ChainEdgeRecord, type ChainEdgeRecord,
type ObjectInstanceRecord, type ObjectInstanceRecord,
@ -22,7 +22,6 @@ import {
type ValidationExplainRecord type ValidationExplainRecord
} from "../../api/queryService"; } from "../../api/queryService";
const SAMPLE_PP_ID = "node_10342";
const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const; const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const;
type ObjectTab = (typeof tabs)[number]; type ObjectTab = (typeof tabs)[number];
@ -33,30 +32,16 @@ interface ObjectDetailPageProps {
export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) { export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) {
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(initialObjectInstanceId ?? null); const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(initialObjectInstanceId ?? null);
const [activeTab, setActiveTab] = useState<ObjectTab>("Validation"); const [activeTab, setActiveTab] = useState<ObjectTab>("Validation");
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const [searchUri, setSearchUri] = useState(""); const [searchUri, setSearchUri] = useState("");
const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun }); const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun });
const runId = latestRunQuery.data?.data.runId ?? "latest"; const runId = latestRunQuery.data?.data.runId ?? "latest";
const objectsQuery = useQuery({
enabled: Boolean(latestRunQuery.data?.data.runId),
queryKey: ["object-detail-sample-objects", runId, SAMPLE_PP_ID],
queryFn: () => listObjectsForPublicationPoint(runId, SAMPLE_PP_ID, 50),
staleTime: 5 * 60 * 1000
});
const objectRows = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]);
const objectOptions = useMemo(
() => objectRows.map((object) => ({
object,
label: `${labelObjectType(object.objectType)} ${shortName(object.uri)}`
})),
[objectRows]
);
useEffect(() => { useEffect(() => {
if (!selectedObjectInstanceId && objectRows.length > 0) { if (initialObjectInstanceId && initialObjectInstanceId !== selectedObjectInstanceId) {
const preferred = objectRows.find((item) => item.objectType === "manifest") ?? objectRows[0]; setSelectedObjectInstanceId(initialObjectInstanceId);
setSelectedObjectInstanceId(preferred.objectInstanceId); setActiveTab("Validation");
} }
}, [objectRows, selectedObjectInstanceId]); }, [initialObjectInstanceId, selectedObjectInstanceId]);
const objectQuery = useQuery({ const objectQuery = useQuery({
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
@ -64,6 +49,7 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""), queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""),
staleTime: 5 * 60 * 1000 staleTime: 5 * 60 * 1000
}); });
const selectedObject = objectQuery.data?.data ?? null;
const validationQuery = useQuery({ const validationQuery = useQuery({
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
queryKey: ["object-detail-validation", runId, selectedObjectInstanceId], queryKey: ["object-detail-validation", runId, selectedObjectInstanceId],
@ -113,44 +99,43 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : []) mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : [])
}); });
const ppExportMutation = useMutation({ const ppExportMutation = useMutation({
mutationFn: () => createPublicationPointExport(runId, selectedObject?.ppId ?? SAMPLE_PP_ID) mutationFn: () => {
if (!selectedObject?.ppId) {
throw new Error("No publication point selected");
}
return createPublicationPointExport(runId, selectedObject.ppId);
}
}); });
const selectedObject = objectQuery.data?.data ?? objectRows.find((item) => item.objectInstanceId === selectedObjectInstanceId) ?? null; useEffect(() => {
if (copyState === "idle") {
return;
}
const timeout = window.setTimeout(() => setCopyState("idle"), 1800);
return () => window.clearTimeout(timeout);
}, [copyState]);
const copySelectedUri = async () => {
if (!selectedObject?.uri) {
return;
}
try {
await navigator.clipboard.writeText(selectedObject.uri);
setCopyState("copied");
} catch {
setCopyState("failed");
}
};
const validation = validationQuery.data?.data ?? null; const validation = validationQuery.data?.data ?? null;
const apiError = latestRunQuery.error ?? objectsQuery.error ?? objectQuery.error ?? validationQuery.error; const apiError = latestRunQuery.error ?? objectQuery.error ?? validationQuery.error;
return ( return (
<section className="page-stack object-page" aria-labelledby="object-detail-heading"> <section className="page-stack object-page" aria-labelledby="object-detail-heading">
{apiError ? <ErrorBanner error={apiError} /> : null} {apiError ? <ErrorBanner error={apiError} /> : null}
<div className="object-layout live-object-layout"> <div className="object-detail-only-layout">
<aside className="repository-tree" aria-label="Publication point object list"> <aside className="object-detail-card object-detail-card-expanded" aria-label="Live object detail">
<p className="eyebrow">Live object list</p> <div className="object-detail-toolbar">
<h3>sakuya.nat.moe sample PP</h3>
<div className="tree-node root">{latestRunQuery.data?.data.runId ?? "Loading run"}</div>
{objectsQuery.isFetching ? <LoadingLine label="Loading PP objects..." /> : null}
<div className="object-picker-list">
{objectOptions.map(({ object, label }) => (
<button
className={`tree-node nested object-picker ${object.objectInstanceId === selectedObjectInstanceId ? "current" : ""}`}
key={object.objectInstanceId}
onClick={() => {
setSelectedObjectInstanceId(object.objectInstanceId);
setActiveTab("Validation");
explainMutation.reset();
}}
type="button"
>
<strong>{object.objectType}</strong>
<span>{label}</span>
</button>
))}
</div>
</aside>
<div className="object-main">
<section className="object-browser" aria-label="Publication point object table">
<div className="browser-toolbar">
<form <form
className="search-box object-uri-search" className="search-box object-uri-search"
onSubmit={(event) => { onSubmit={(event) => {
@ -168,53 +153,30 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
value={searchUri} value={searchUri}
/> />
</form> </form>
<button className="filter-pill" type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button> <button className="filter-pill" disabled={!selectedObject?.uri} type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button>
<strong>{objectRows.length} objects</strong> <strong>{selectedObject ? `${labelObjectType(selectedObject.objectType)} selected` : "No object selected"}</strong>
</div> </div>
<table> {!selectedObjectInstanceId ? <div className="empty-state">Search an exact URI or open an object from Repository Browser.</div> : null}
<thead> {selectedObjectInstanceId && objectQuery.isFetching ? <LoadingLine label="Loading selected object..." /> : null}
<tr>
<th>Type</th>
<th>URI</th>
<th>Hash (SHA-256)</th>
<th>Source</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{objectRows.map((object) => (
<tr
className={object.objectInstanceId === selectedObjectInstanceId ? "object-row-active" : ""}
key={object.objectInstanceId}
onClick={() => setSelectedObjectInstanceId(object.objectInstanceId)}
>
<td><span className="object-type">{labelObjectType(object.objectType)}</span></td>
<td className="uri-cell">{object.uri}</td>
<td>{object.sha256.slice(0, 16)}...</td>
<td>{object.sourceSection}</td>
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
</tr>
))}
</tbody>
</table>
</section>
<aside className="object-detail-card" aria-label="Live object detail">
<div className="object-hero"> <div className="object-hero">
<div> <div>
<p className="eyebrow">Object detail · live query service</p> <p className="eyebrow">Object detail · live query service</p>
<h2 id="object-detail-heading">{selectedObject ? labelObjectType(selectedObject.objectType) : "Object"} object</h2> <h2 id="object-detail-heading">{selectedObject ? labelObjectType(selectedObject.objectType) : "Object"} object</h2>
<p>{selectedObject?.uri ?? "Select an object from the publication point list."}</p> {selectedObject ? (
<p><CopyableValue className="inline-copyable" label="selected object URI" value={selectedObject.uri} /></p>
) : (
<p>Select an object from the publication point list.</p>
)}
</div> </div>
<div className="object-actions"> <div className="object-actions">
<StatusPill state={validation?.finalStatus ?? selectedObject?.result ?? "loading"} /> <StatusPill state={validation?.finalStatus ?? selectedObject?.result ?? "loading"} />
<button className="ghost-button" type="button"> <button className="ghost-button" disabled={!selectedObject?.uri} onClick={copySelectedUri} type="button">
<Copy size={16} /> Copy URI <Copy size={16} /> {copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : "Copy URI"}
</button> </button>
<button className="ghost-button" onClick={() => objectExportMutation.mutate()} type="button"> <button className="ghost-button" disabled={!selectedObjectInstanceId} onClick={() => objectExportMutation.mutate()} type="button">
<FileText size={16} /> Export object <FileText size={16} /> Export object
</button> </button>
<button className="primary-button" onClick={() => rawDownloadMutation.mutate()} type="button"> <button className="primary-button" disabled={!selectedObjectInstanceId} onClick={() => rawDownloadMutation.mutate()} type="button">
<Download size={16} /> Download raw <Download size={16} /> Download raw
</button> </button>
</div> </div>
@ -232,19 +194,32 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
searchPending={uriSearchMutation.isPending} searchPending={uriSearchMutation.isPending}
/> />
<div className="object-actions inline-actions"> <div className="object-actions inline-actions">
<button className="ghost-button" onClick={() => ppExportMutation.mutate()} type="button"> <button className="ghost-button" disabled={!selectedObject?.ppId} onClick={() => ppExportMutation.mutate()} type="button">
<FileText size={16} /> Export selected PP <FileText size={16} /> Export selected PP
</button> </button>
</div> </div>
<div className="tabs" role="tablist" aria-label="Object detail tabs"> <div className="tabs" role="tablist" aria-label="Object detail tabs" onKeyDown={(event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
event.preventDefault();
const currentIndex = tabs.indexOf(activeTab);
const nextIndex = event.key === "ArrowRight"
? (currentIndex + 1) % tabs.length
: (currentIndex - 1 + tabs.length) % tabs.length;
setActiveTab(tabs[nextIndex]);
}}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
aria-controls={`object-tabpanel-${tabId(tab)}`}
aria-selected={activeTab === tab} aria-selected={activeTab === tab}
className={activeTab === tab ? "active" : ""} className={activeTab === tab ? "active" : ""}
id={`object-tab-${tabId(tab)}`}
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
role="tab" role="tab"
tabIndex={activeTab === tab ? 0 : -1}
type="button" type="button"
> >
{tab} {tab}
@ -252,7 +227,12 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
))} ))}
</div> </div>
<div className="object-content-grid"> <div
aria-labelledby={`object-tab-${tabId(activeTab)}`}
className="object-content-grid"
id={`object-tabpanel-${tabId(activeTab)}`}
role="tabpanel"
>
{activeTab === "Parsed" ? <ParsedPanel parsed={parsedQuery.data?.data ?? null} isFetching={parsedQuery.isFetching} error={parsedQuery.error} /> : null} {activeTab === "Parsed" ? <ParsedPanel parsed={parsedQuery.data?.data ?? null} isFetching={parsedQuery.isFetching} error={parsedQuery.error} /> : null}
{activeTab === "Validation" ? ( {activeTab === "Validation" ? (
<ValidationPanel validation={validation} isFetching={validationQuery.isFetching} explain={explainMutation.data?.data ?? null} explainPending={explainMutation.isPending} onExplain={() => explainMutation.mutate()} explainError={explainMutation.error} /> <ValidationPanel validation={validation} isFetching={validationQuery.isFetching} explain={explainMutation.data?.data ?? null} explainPending={explainMutation.isPending} onExplain={() => explainMutation.mutate()} explainError={explainMutation.error} />
@ -263,7 +243,6 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
</div> </div>
</aside> </aside>
</div> </div>
</div>
</section> </section>
); );
} }
@ -271,10 +250,10 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) { function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) {
return ( return (
<div className="object-meta-grid"> <div className="object-meta-grid">
<Meta label="SHA256" value={object.sha256} /> <Meta label="SHA256" value={object.sha256} copyable />
<Meta label="Source" value={object.sourceSection} /> <Meta label="Source" value={object.sourceSection} />
<Meta label="Repository" value={object.repoId} /> <Meta label="Repository" value={object.repoId} copyable />
<Meta label="Publication Point" value={object.ppId} /> <Meta label="Publication Point" value={object.ppId} copyable />
<Meta label="Audit Result" value={validation?.auditResult ?? object.result} /> <Meta label="Audit Result" value={validation?.auditResult ?? object.result} />
<Meta label="Authoritative" value={validation ? String(validation.authoritative) : "loading"} /> <Meta label="Authoritative" value={validation ? String(validation.authoritative) : "loading"} />
</div> </div>
@ -398,7 +377,7 @@ function ChainPanel({ edges, isFetching, error }: { edges: ChainEdgeRecord[]; is
<div className="chain-node" key={`${edge.relation}-${edge.toUri}`}> <div className="chain-node" key={`${edge.relation}-${edge.toUri}`}>
<strong>{edge.relation}</strong> <strong>{edge.relation}</strong>
<span>{edge.status}</span> <span>{edge.status}</span>
<code>{edge.toUri}</code> <code><CopyableValue label="chain edge URI" value={edge.toUri} /></code>
</div> </div>
))} ))}
</div> </div>
@ -498,11 +477,11 @@ function LoadingLine({ label }: { label: string }) {
); );
} }
function Meta({ label, value }: { label: string; value: string }) { function Meta({ copyable, label, value }: { copyable?: boolean; label: string; value: string }) {
return ( return (
<div className="meta-item"> <div className="meta-item">
<span>{label}</span> <span>{label}</span>
<strong>{value}</strong> <strong>{copyable ? <CopyableValue label={label} value={value} /> : value}</strong>
</div> </div>
); );
} }
@ -518,8 +497,8 @@ function labelObjectType(type: string) {
return labels[type] ?? type.toUpperCase(); return labels[type] ?? type.toUpperCase();
} }
function shortName(uri: string) { function tabId(tab: ObjectTab) {
return uri.split("/").filter(Boolean).at(-1) ?? uri; return tab.toLowerCase().replaceAll(" ", "-");
} }
function issuesText(issues: { summary?: string }[] | undefined) { function issuesText(issues: { summary?: string }[] | undefined) {

View File

@ -12,6 +12,7 @@ import {
type RunRecord type RunRecord
} from "../../api/queryService"; } from "../../api/queryService";
import { normalizeApiError } from "../../api/client"; import { normalizeApiError } from "../../api/client";
import { CopyableValue } from "../../components/common/CopyableValue";
const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"]; const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"];
const objectTypeLabels: Record<string, string> = { const objectTypeLabels: Record<string, string> = {
@ -58,7 +59,7 @@ export function OverviewPage() {
const objectRows = objectTypesQuery.data?.data ? objectRowsFromStats(objectTypesQuery.data.data) : objectTypeRows; const objectRows = objectTypesQuery.data?.data ? objectRowsFromStats(objectTypesQuery.data.data) : objectTypeRows;
const validationRows = validationQuery.data?.data ? validationRowsFromStats(validationQuery.data.data) : validationSlices; const validationRows = validationQuery.data?.data ? validationRowsFromStats(validationQuery.data.data) : validationSlices;
const validPercent = percent(validationQuery.data?.data?.ok ?? validationRows[0]?.value ?? 0, sumValues(validationQuery.data?.data) || 100); const validPercent = percent(validationQuery.data?.data?.ok ?? validationRows[0]?.value ?? 0, sumValues(validationQuery.data?.data) || 100);
const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : topRepositories; const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : reposFromFixtures();
const issueRows = reasonsQuery.data?.data ? issuesFromReasons(reasonsQuery.data.data) : validationIssues; const issueRows = reasonsQuery.data?.data ? issuesFromReasons(reasonsQuery.data.data) : validationIssues;
const statusText = liveRun ? statusForRun(liveRun) : "Static fixture"; const statusText = liveRun ? statusForRun(liveRun) : "Static fixture";
const statusSubtext = liveRun ? `run ${liveRun.runSeq ?? liveRun.runId} · ${formatDuration(liveRun.wallMs)}` : "query service not connected"; const statusSubtext = liveRun ? `run ${liveRun.runSeq ?? liveRun.runId} · ${formatDuration(liveRun.wallMs)}` : "query service not connected";
@ -172,7 +173,7 @@ export function OverviewPage() {
</thead> </thead>
<tbody> <tbody>
{repoRows.map((repo) => ( {repoRows.map((repo) => (
<tr key={repo.host}> <tr key={repo.id}>
<td>{repo.host}</td> <td>{repo.host}</td>
<td>{repo.transport}</td> <td>{repo.transport}</td>
<td>{repo.duration}</td> <td>{repo.duration}</td>
@ -202,7 +203,7 @@ export function OverviewPage() {
<strong>{issue.type}</strong> <strong>{issue.type}</strong>
</div> </div>
<p>{issue.reason}</p> <p>{issue.reason}</p>
<code>{issue.uri}</code> <code><CopyableValue label="issue URI" value={issue.uri} /></code>
<small>{issue.repo}</small> <small>{issue.repo}</small>
</article> </article>
))} ))}
@ -253,6 +254,7 @@ function reposFromApi(repos: RepositoryRecord[]) {
.slice(0, 6) .slice(0, 6)
.map((repo) => ({ .map((repo) => ({
host: repo.host, host: repo.host,
id: repo.repoId,
transport: repo.transport.toUpperCase(), transport: repo.transport.toUpperCase(),
duration: formatDuration(repo.syncDurationMsTotal), duration: formatDuration(repo.syncDurationMsTotal),
objects: formatNumber(repo.objects), objects: formatNumber(repo.objects),
@ -260,6 +262,17 @@ function reposFromApi(repos: RepositoryRecord[]) {
})); }));
} }
function reposFromFixtures() {
return topRepositories.map((repo, index) => ({
...repo,
id: fallbackRepoId(repo, index)
}));
}
function fallbackRepoId(repo: { host: string; transport: string; duration: string; objects: string }, index: number) {
return `${repo.host}-${repo.transport}-${repo.duration}-${repo.objects}-${index}`;
}
function issuesFromReasons(reasons: CountsByKey) { function issuesFromReasons(reasons: CountsByKey) {
const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left); const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left);
if (entries.length === 0) { if (entries.length === 0) {

View File

@ -1,51 +1,101 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Database, FileCode2, GitBranch, Loader2 } from "lucide-react"; import { ChevronRight, Database, FileCode2, GitBranch, Loader2, PanelLeftClose, PanelRightClose } from "lucide-react";
import type { ReactNode } from "react"; import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useState } 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 { import {
getLatestRun, getLatestRun,
listObjectsForPublicationPoint, listObjectsForPublicationPoint,
listPublicationPointsForRepo, listPublicationPointsForRepo,
listRepos, listRepos,
type ObjectInstanceRecord type ObjectInstanceRecord,
type PublicationPointRecord,
type RepositoryRecord
} from "../../api/queryService"; } from "../../api/queryService";
import { normalizeApiError } from "../../api/client"; import { normalizeApiError } from "../../api/client";
const REPO_PAGE_SIZE = 50;
const PP_PAGE_SIZE = 50;
const OBJECT_PAGE_SIZE = 50;
interface RepositoriesPageProps { interface RepositoriesPageProps {
browserState: RepositoryBrowserState;
onBrowserStateChange: Dispatch<SetStateAction<RepositoryBrowserState>>;
onOpenObject?: (objectInstanceId: string) => void; onOpenObject?: (objectInstanceId: string) => void;
} }
export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) { export function RepositoriesPage({ browserState, onBrowserStateChange, onOpenObject }: RepositoriesPageProps) {
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null); const {
const [selectedPpId, setSelectedPpId] = useState<string | null>(null); 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({ const latestRunQuery = useQuery({
queryKey: ["repositories-latest-run"], queryKey: ["repositories-latest-run"],
queryFn: getLatestRun queryFn: getLatestRun
}); });
const runId = latestRunQuery.data?.data.runId ?? "latest"; const runId = latestRunQuery.data?.data.runId ?? "latest";
const reposQuery = useQuery({ const reposQuery = useQuery({
queryKey: ["repositories", runId], queryKey: ["repositories", runId, repoPage.cursor],
queryFn: () => listRepos(runId, 50), queryFn: () => listRepos(runId, REPO_PAGE_SIZE, repoPage.cursor),
enabled: Boolean(latestRunQuery.data?.data.runId) enabled: Boolean(latestRunQuery.data?.data.runId)
}); });
const repos = reposQuery.data?.data ?? []; 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 selectedRepo = selectedRepoId ? repos.find((repo) => repo.repoId === selectedRepoId) ?? null : null;
const ppsQuery = useQuery({ const ppsQuery = useQuery({
queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? ""], queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? "", ppPage.cursor],
queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", 50), queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", PP_PAGE_SIZE, ppPage.cursor),
enabled: Boolean(selectedRepo?.repoId) enabled: Boolean(selectedRepo?.repoId)
}); });
const publicationPoints = ppsQuery.data?.data ?? []; const publicationPoints = useMemo(() => ppsQuery.data?.data ?? [], [ppsQuery.data?.data]);
const filteredPublicationPoints = useMemo(() => filterPublicationPoints(publicationPoints, ppFilter), [ppFilter, publicationPoints]);
const selectedPp = selectedPpId const selectedPp = selectedPpId
? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null ? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null
: null; : null;
const objectsQuery = useQuery({ const objectsQuery = useQuery({
queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? ""], queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? "", objectPage.cursor],
queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", 50), queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", OBJECT_PAGE_SIZE, objectPage.cursor),
enabled: Boolean(selectedPp?.ppId) 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; const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error;
@ -73,94 +123,236 @@ export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) {
</div> </div>
) : null} ) : null}
<div className="repo-browser-grid"> <div className={`repo-browser-grid ${isRepoPanelCollapsed ? "repo-panel-collapsed" : ""} ${isPpPanelCollapsed ? "pp-panel-collapsed" : ""}`}>
<section className="panel list-panel" aria-label="Repositories"> <section className="panel list-panel collapsible-list-panel repo-list-panel" aria-label="Repositories">
<PanelTitle icon={<Database size={18} />} label="Repositories" meta={reposQuery.isFetching ? "loading" : `${repos.length} shown`} /> <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"> <div className="stack-list">
{repos.map((repo) => ( {filteredRepos.map((repo) => (
<article className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`} key={repo.repoId}>
<button <button
className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`} className="stack-row-select"
key={repo.repoId}
onClick={() => { onClick={() => {
setSelectedRepoId(repo.repoId); onBrowserStateChange((state) => ({
setSelectedPpId(null); ...state,
selectedRepoId: repo.repoId,
selectedPpId: null,
ppFilter: "",
objectFilter: "",
ppPage: createEmptyPageState(),
objectPage: createEmptyPageState()
}));
}} }}
type="button" type="button"
> >
<span> <span>
<strong>{repo.host}</strong> <strong>{repo.host}</strong>
<small>{repo.uri}</small> <small>{repo.transport.toUpperCase()} · {repo.publicationPoints.toLocaleString()} publication points</small>
</span> </span>
<em>{repo.objects.toLocaleString()}</em> <em>{repo.objects.toLocaleString()}</em>
<ChevronRight size={16} /> <ChevronRight size={16} />
</button> </button>
<CopyableValue className="stack-row-copy" label="repository URI" value={repo.uri} />
</article>
))} ))}
</div> </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>
<section className="panel list-panel" aria-label="Publication points"> <section className="panel list-panel collapsible-list-panel pp-list-panel" aria-label="Publication points">
<PanelTitle <PanelTitle
icon={<GitBranch size={18} />} icon={<GitBranch size={18} />}
isCollapsed={isPpPanelCollapsed}
label="Publication Points" label="Publication Points"
meta={ppsQuery.isFetching ? "loading" : `${publicationPoints.length} shown`} 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 ? ( {!selectedRepo ? (
<div className="empty-state">Select a repository to load its publication points.</div> <div className="empty-state">Select a repository to load its publication points.</div>
) : null} ) : null}
<CurrentPageFilter
disabled={!selectedRepo}
label="Filter publication points on current page"
onChange={setPpFilter}
placeholder="Filter manifest, rsync, source..."
value={ppFilter}
/>
{ppsQuery.isFetching ? <LoadingLine /> : null} {ppsQuery.isFetching ? <LoadingLine /> : null}
<div className="stack-list"> <div className="stack-list">
{publicationPoints.map((pp) => ( {filteredPublicationPoints.map((pp) => (
<article className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`} key={pp.ppId}>
<button <button
className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`} className="stack-row-select"
key={pp.ppId} onClick={() => {
onClick={() => setSelectedPpId(pp.ppId)} onBrowserStateChange((state) => ({
...state,
selectedPpId: pp.ppId,
objectFilter: "",
objectPage: createEmptyPageState()
}));
}}
type="button" type="button"
> >
<span> <span>
<strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong> <strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong>
<small>{pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? "unknown"}</small> <small>{pp.repoSyncSource ?? pp.source ?? "unknown"} · {pp.ppId}</small>
</span> </span>
<em>{pp.objects.toLocaleString()}</em> <em>{pp.objects.toLocaleString()}</em>
<StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} /> <StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} />
</button> </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> </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>
<section className="panel table-panel objects-live-panel" aria-label="Objects for publication point"> <section className="panel table-panel objects-live-panel" aria-label="Objects for publication point">
<PanelTitle <PanelTitle
icon={<FileCode2 size={18} />} icon={<FileCode2 size={18} />}
label="Objects" label="Objects"
meta={objectsQuery.isFetching ? "loading" : `${objectsQuery.data?.data.length ?? 0} of selected PP`} meta={objectsQuery.isFetching ? "loading" : selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : "select PP"}
/> />
{!selectedPp ? ( {!selectedPp ? (
<div className="empty-state">Select a publication point to load its objects.</div> <div className="empty-state">Select a publication point to load its objects.</div>
) : null} ) : null}
<CurrentPageFilter
disabled={!selectedPp}
label="Filter objects on current page"
onChange={setObjectFilter}
placeholder="Filter type, URI, hash, status..."
value={objectFilter}
/>
{objectsQuery.isFetching ? <LoadingLine /> : null} {objectsQuery.isFetching ? <LoadingLine /> : null}
{selectedPp && !objectsQuery.isFetching ? ( {selectedPp && !objectsQuery.isFetching ? (
<ObjectTable objects={objectsQuery.data?.data ?? []} onOpenObject={onOpenObject} /> <ObjectTable objects={filteredObjects} onOpenObject={onOpenObject} />
) : null} ) : null}
<div className="pagination-note"> <CursorPager
Default query uses <code>limit=50</code>. Next cursor: <code>{objectsQuery.data?.page?.nextCursor ?? "none"}</code> isFetching={objectsQuery.isFetching}
</div> 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> </section>
</div> </div>
</section> </section>
); );
} }
function PanelTitle({ icon, label, meta }: { icon: ReactNode; label: string; meta: string }) { function PanelTitle({
icon,
isCollapsed,
label,
meta,
onToggleCollapse,
toggleLabel
}: {
icon: ReactNode;
isCollapsed?: boolean;
label: string;
meta: string;
onToggleCollapse?: () => void;
toggleLabel?: string;
}) {
return ( return (
<div className="panel-heading compact-heading"> <div className="panel-heading compact-heading">
<div> <div>
<p className="eyebrow">{label}</p> <p className="eyebrow">{label}</p>
<h3>{label}</h3> <h3>{label}</h3>
</div> </div>
<div className="panel-heading-actions">
<span className="panel-meta"> <span className="panel-meta">
{icon} {icon}
{meta} {meta}
</span> </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>
</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>
); );
} }
@ -184,8 +376,8 @@ function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[
{objects.map((object) => ( {objects.map((object) => (
<tr key={object.objectInstanceId}> <tr key={object.objectInstanceId}>
<td><span className="object-type">{object.objectType}</span></td> <td><span className="object-type">{object.objectType}</span></td>
<td className="uri-cell">{object.uri}</td> <td className="uri-cell"><CopyableValue label="object URI" value={object.uri} /></td>
<td>{object.sha256.slice(0, 12)}...</td> <td><CopyableValue label="object SHA256" value={object.sha256} displayValue={shortHash(object.sha256, 12)} /></td>
<td>{object.sourceSection}</td> <td>{object.sourceSection}</td>
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td> <td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
<td> <td>
@ -222,3 +414,54 @@ function LoadingLine() {
function shortName(uri: string) { function shortName(uri: string) {
return uri.split("/").filter(Boolean).at(-1) ?? uri; 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));
}

View File

@ -0,0 +1,33 @@
import type { PageState } from "../../components/common/cursorPaging";
export interface RepositoryBrowserState {
selectedRepoId: string | null;
selectedPpId: string | null;
repoFilter: string;
ppFilter: string;
objectFilter: string;
repoPage: PageState;
ppPage: PageState;
objectPage: PageState;
isRepoPanelCollapsed: boolean;
isPpPanelCollapsed: boolean;
}
export function createRepositoryBrowserState(): RepositoryBrowserState {
return {
selectedRepoId: null,
selectedPpId: null,
repoFilter: "",
ppFilter: "",
objectFilter: "",
repoPage: createEmptyPageState(),
ppPage: createEmptyPageState(),
objectPage: createEmptyPageState(),
isRepoPanelCollapsed: false,
isPpPanelCollapsed: false
};
}
export function createEmptyPageState(): PageState {
return { cursor: null, previous: [] };
}

View File

@ -15,6 +15,7 @@ body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden;
background: #f7faff; background: #f7faff;
} }
@ -38,12 +39,20 @@ input:focus-visible {
grid-template-columns: 230px minmax(0, 1fr); grid-template-columns: 230px minmax(0, 1fr);
min-height: 100vh; min-height: 100vh;
background: linear-gradient(180deg, #ffffff 0%, #f4f8fe 100%); background: linear-gradient(180deg, #ffffff 0%, #f4f8fe 100%);
transition: grid-template-columns 180ms ease;
}
.app-shell.sidebar-collapsed {
grid-template-columns: 76px minmax(0, 1fr);
} }
.sidebar { .sidebar {
position: sticky; position: sticky;
display: flex;
flex-direction: column;
top: 0; top: 0;
height: 100vh; height: 100vh;
overflow: hidden;
border-right: 1px solid #dbe4f0; border-right: 1px solid #dbe4f0;
background: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.88);
color: #10213f; color: #10213f;
@ -58,7 +67,13 @@ input:focus-visible {
padding: 0 10px; padding: 0 10px;
} }
.brand-copy {
min-width: 0;
transition: opacity 160ms ease;
}
.brand-mark { .brand-mark {
flex: 0 0 36px;
display: grid; display: grid;
width: 36px; width: 36px;
height: 36px; height: 36px;
@ -85,6 +100,33 @@ input:focus-visible {
text-transform: uppercase; text-transform: uppercase;
} }
.sidebar-toggle {
display: flex;
width: 100%;
align-items: center;
justify-content: flex-start;
gap: 10px;
border: 1px solid #d2deee;
border-radius: 9px;
background: #ffffff;
color: #24466f;
font-weight: 800;
margin-top: auto;
padding: 9px 12px;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.sidebar-toggle:hover,
.sidebar-toggle:focus-visible {
border-color: #b7d2fb;
background: #eaf3ff;
color: #0759d7;
}
.sidebar-toggle svg {
flex: 0 0 auto;
}
.eyebrow { .eyebrow {
margin-bottom: 8px; margin-bottom: 8px;
color: #60718e; color: #60718e;
@ -97,6 +139,7 @@ input:focus-visible {
.nav-list { .nav-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
margin-bottom: 16px;
} }
.nav-item { .nav-item {
@ -110,6 +153,7 @@ input:focus-visible {
background: transparent; background: transparent;
color: #122544; color: #122544;
text-align: left; text-align: left;
transition: background 160ms ease, color 160ms ease;
} }
.nav-item.active, .nav-item.active,
@ -119,6 +163,7 @@ input:focus-visible {
} }
.nav-item svg { .nav-item svg {
flex: 0 0 auto;
color: #24466f; color: #24466f;
} }
@ -127,12 +172,51 @@ input:focus-visible {
color: #0759d7; color: #0759d7;
} }
.sidebar-collapsed .sidebar {
padding-inline: 12px;
}
.sidebar-collapsed .brand {
justify-content: center;
margin-bottom: 18px;
padding: 0;
}
.sidebar-collapsed .brand-copy,
.sidebar-collapsed .nav-item span,
.sidebar-collapsed .sidebar-toggle span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.sidebar-collapsed .sidebar-toggle,
.sidebar-collapsed .nav-item {
justify-content: center;
padding-inline: 0;
}
.sidebar-collapsed .sidebar-toggle {
width: 42px;
height: 42px;
margin-top: auto;
margin-inline: auto;
}
.sidebar-collapsed .nav-item {
height: 44px;
}
.main-panel { .main-panel {
min-width: 0; min-width: 0;
} }
.topbar { .topbar {
display: flex; display: flex;
min-width: 0;
min-height: 76px; min-height: 76px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -165,6 +249,7 @@ h3 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 18px; gap: 18px;
max-width: 100%;
min-width: 206px; min-width: 206px;
border: 1px solid #d2deee; border: 1px solid #d2deee;
border-radius: 9px; border-radius: 9px;
@ -185,6 +270,7 @@ h3 {
.topbar-actions { .topbar-actions {
display: flex; display: flex;
flex: 1; flex: 1;
min-width: 0;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 14px; gap: 14px;
@ -194,6 +280,7 @@ h3 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0;
width: min(740px, 100%); width: min(740px, 100%);
border: 1px solid #d2deee; border: 1px solid #d2deee;
border-radius: 9px; border-radius: 9px;
@ -265,6 +352,7 @@ h3 {
.hero-dashboard { .hero-dashboard {
display: flex; display: flex;
min-width: 0;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 20px;
@ -287,6 +375,7 @@ h3 {
.hero-status { .hero-status {
display: flex; display: flex;
max-width: 100%;
min-width: 230px; min-width: 230px;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@ -391,8 +480,9 @@ h3 {
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: minmax(360px, 0.96fr) minmax(420px, 1.04fr) minmax(360px, 0.92fr); grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr) minmax(0, 0.92fr);
gap: 16px; gap: 16px;
min-width: 0;
} }
.panel, .panel,
@ -402,6 +492,7 @@ h3 {
.tabs, .tabs,
.object-browser, .object-browser,
.object-detail-card { .object-detail-card {
min-width: 0;
border: 1px solid #dbe4f0; border: 1px solid #dbe4f0;
border-radius: 14px; border-radius: 14px;
background: white; background: white;
@ -409,10 +500,15 @@ h3 {
} }
.panel { .panel {
overflow-x: visible;
min-width: 0; min-width: 0;
padding: 18px; padding: 18px;
} }
.table-panel {
overflow-x: auto;
}
.wide-panel { .wide-panel {
grid-column: span 2; grid-column: span 2;
} }
@ -438,16 +534,101 @@ h3 {
font-weight: 800; font-weight: 800;
} }
.panel-heading-actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.panel-collapse-button {
display: inline-grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid #d2deee;
border-radius: 8px;
background: white;
color: #24466f;
}
.panel-collapse-button:hover,
.panel-collapse-button:focus-visible {
border-color: #a9c8f6;
background: #eaf3ff;
color: #0759d7;
}
.repo-browser-grid { .repo-browser-grid {
display: grid; display: grid;
grid-template-columns: minmax(300px, 0.85fr) minmax(340px, 1fr) minmax(520px, 1.35fr); grid-template-columns: minmax(300px, 0.85fr) minmax(340px, 1fr) minmax(520px, 1.35fr);
gap: 16px; gap: 16px;
min-width: 0;
}
.repo-browser-grid.repo-panel-collapsed {
grid-template-columns: 76px minmax(340px, 1fr) minmax(520px, 1.35fr);
}
.repo-browser-grid.pp-panel-collapsed {
grid-template-columns: minmax(300px, 0.85fr) 76px minmax(520px, 1.35fr);
}
.repo-browser-grid.repo-panel-collapsed.pp-panel-collapsed {
grid-template-columns: 76px 76px minmax(520px, 1fr);
} }
.list-panel { .list-panel {
min-height: 640px; min-height: 640px;
} }
.collapsible-list-panel {
min-width: 0;
}
.repo-browser-grid.repo-panel-collapsed .repo-list-panel,
.repo-browser-grid.pp-panel-collapsed .pp-list-panel {
padding: 12px;
}
.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-heading,
.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-heading {
align-items: center;
flex-direction: column;
}
.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-heading > div:first-child,
.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-heading > div:first-child,
.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-meta,
.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-meta {
display: none;
}
.collapsed-panel-summary {
display: grid;
min-height: 520px;
place-items: center;
align-content: center;
gap: 10px;
color: #24466f;
text-align: center;
}
.collapsed-panel-summary strong {
writing-mode: vertical-rl;
color: #07142d;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.collapsed-panel-summary span {
color: #0759d7;
font-size: 12px;
font-weight: 850;
}
.stack-list { .stack-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
@ -458,9 +639,8 @@ h3 {
.stack-row { .stack-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr);
gap: 10px; gap: 8px;
align-items: center;
width: 100%; width: 100%;
border: 1px solid #e3ebf5; border: 1px solid #e3ebf5;
border-radius: 10px; border-radius: 10px;
@ -470,27 +650,40 @@ h3 {
text-align: left; text-align: left;
} }
.stack-row-select {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
border: 0;
background: transparent;
color: inherit;
padding: 0;
text-align: left;
}
.stack-row.active, .stack-row.active,
.stack-row:hover { .stack-row:hover {
border-color: #b7d2fb; border-color: #b7d2fb;
background: #eaf3ff; background: #eaf3ff;
} }
.stack-row strong, .stack-row-select strong,
.stack-row small { .stack-row-select small {
display: block; display: block;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.stack-row small { .stack-row-select small {
margin-top: 4px; margin-top: 4px;
color: #60718e; color: #60718e;
font-size: 12px; font-size: 12px;
} }
.stack-row em { .stack-row-select em {
color: #0759d7; color: #0759d7;
font-size: 12px; font-size: 12px;
font-style: normal; font-style: normal;
@ -498,6 +691,7 @@ h3 {
} }
.objects-live-panel { .objects-live-panel {
overflow-x: auto;
min-width: 0; min-width: 0;
} }
@ -518,6 +712,163 @@ h3 {
padding: 6px 9px; padding: 6px 9px;
} }
.copyable-value {
position: relative;
display: inline-flex;
max-width: 100%;
min-width: 0;
align-items: center;
gap: 5px;
vertical-align: middle;
}
.copyable-value-text {
display: inline-block;
max-width: min(100%, 360px);
min-width: 0;
overflow: hidden;
color: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
.inline-copyable {
width: 100%;
}
.inline-copyable .copyable-value-text {
max-width: min(100%, 520px);
}
.stack-row-copy {
width: 100%;
color: #60718e;
font-size: 12px;
}
.stack-row-copy.secondary {
color: #24466f;
}
.stack-row-copy .copyable-value-text {
max-width: 100%;
}
.copy-icon-button {
display: inline-grid;
flex: 0 0 auto;
width: 22px;
height: 22px;
place-items: center;
border: 1px solid #d2deee;
border-radius: 7px;
background: white;
color: #0b5bd3;
padding: 0;
}
.copy-icon-button:hover,
.copy-icon-button:focus-visible {
border-color: #a9c8f6;
background: #eaf3ff;
}
.copy-state-text {
color: #047857;
font-size: 11px;
font-weight: 850;
}
.copyable-tooltip {
position: fixed;
z-index: 1000;
max-width: min(760px, calc(100vw - 32px));
transform: translate(-50%, -100%);
border: 1px solid #b7d2fb;
border-radius: 10px;
background: #07142d;
box-shadow: 0 16px 42px rgba(7, 20, 45, 0.22);
color: white;
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
padding: 9px 11px;
pointer-events: none;
white-space: normal;
}
.current-page-filter {
display: grid;
gap: 6px;
margin-bottom: 12px;
}
.current-page-filter span {
color: #60718e;
font-size: 11px;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.current-page-filter input {
width: 100%;
border: 1px solid #d2deee;
border-radius: 8px;
background: white;
color: #0f172a;
padding: 9px 10px;
}
.current-page-filter input:disabled {
background: #f7faff;
color: #94a3b8;
}
.cursor-pager {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-top: 12px;
color: #60718e;
font-size: 12px;
}
.cursor-pager span {
display: grid;
gap: 2px;
min-width: 160px;
color: #122544;
font-weight: 850;
text-align: center;
}
.cursor-pager small {
color: #60718e;
font-size: 11px;
font-weight: 750;
}
.cursor-pager button {
border: 1px solid #d2deee;
border-radius: 7px;
background: white;
color: #0b5bd3;
font-weight: 800;
padding: 7px 10px;
}
.cursor-pager button:disabled,
.ghost-button:disabled,
.primary-button:disabled,
.table-link-button:disabled,
.filter-pill:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.pagination-note, .pagination-note,
.empty-state, .empty-state,
.loading-line { .loading-line {
@ -639,6 +990,7 @@ h3 {
table { table {
width: 100%; width: 100%;
min-width: 620px;
border-collapse: collapse; border-collapse: collapse;
} }
@ -693,6 +1045,8 @@ td {
} }
.issue-card { .issue-card {
min-width: 0;
max-width: 100%;
border-bottom: 1px solid #e3ebf5; border-bottom: 1px solid #e3ebf5;
padding: 14px 0; padding: 14px 0;
} }
@ -703,17 +1057,23 @@ td {
.issue-card div { .issue-card div {
display: flex; display: flex;
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.issue-card p { .issue-card p {
max-width: 100%;
overflow-wrap: anywhere;
margin: 9px 0; margin: 9px 0;
color: #122544; color: #122544;
} }
.issue-card code { .issue-card code {
display: block; display: block;
max-width: 100%;
overflow: hidden; overflow: hidden;
color: #53657f; color: #53657f;
font-size: 12px; font-size: 12px;
@ -723,6 +1083,8 @@ td {
.issue-card small { .issue-card small {
display: block; display: block;
max-width: 100%;
overflow-wrap: anywhere;
margin-top: 7px; margin-top: 7px;
color: #60718e; color: #60718e;
} }
@ -774,6 +1136,40 @@ td {
grid-template-columns: 248px minmax(350px, 1fr) minmax(330px, 0.9fr); grid-template-columns: 248px minmax(350px, 1fr) minmax(330px, 0.9fr);
min-height: calc(100vh - 76px); min-height: calc(100vh - 76px);
gap: 0; gap: 0;
min-width: 0;
}
.object-detail-only-layout {
min-height: calc(100vh - 76px);
min-width: 0;
padding: 18px;
}
.object-detail-card-expanded {
width: min(100%, 1120px);
margin: 0 auto;
}
.object-detail-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #dbe4f0;
margin-bottom: 18px;
padding-bottom: 14px;
}
.object-detail-toolbar .search-box {
flex: 1 1 420px;
max-width: 580px;
padding: 9px 12px;
}
.object-detail-toolbar strong {
color: #24466f;
font-size: 13px;
} }
.repository-tree { .repository-tree {
@ -818,17 +1214,27 @@ td {
} }
.object-picker { .object-picker {
display: block; display: grid;
gap: 6px;
width: calc(100% - 18px); width: calc(100% - 18px);
}
.object-picker-select {
display: block;
width: 100%;
border: 0;
background: transparent;
color: inherit;
padding: 0;
text-align: left; text-align: left;
} }
.object-picker strong, .object-picker-select strong,
.object-picker span { .object-picker-select span {
display: block; display: block;
} }
.object-picker span { .object-picker-select span {
overflow: hidden; overflow: hidden;
margin-top: 4px; margin-top: 4px;
color: #60718e; color: #60718e;
@ -842,7 +1248,8 @@ td {
} }
.object-browser { .object-browser {
overflow: hidden; overflow-x: auto;
overflow-y: hidden;
border-top: 0; border-top: 0;
border-bottom: 0; border-bottom: 0;
border-radius: 0; border-radius: 0;
@ -851,6 +1258,7 @@ td {
.browser-toolbar { .browser-toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
@ -859,6 +1267,7 @@ td {
} }
.browser-toolbar .search-box { .browser-toolbar .search-box {
flex: 1 1 220px;
max-width: 240px; max-width: 240px;
padding: 9px 12px; padding: 9px 12px;
} }
@ -885,14 +1294,26 @@ td {
font-weight: 850; font-weight: 850;
} }
.object-row-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.object-detail-card { .object-detail-card {
align-self: start; align-self: start;
margin: 14px; margin: 14px;
padding: 18px; padding: 18px;
} }
.object-detail-card.object-detail-card-expanded {
margin: 0 auto;
}
.object-hero { .object-hero {
display: flex; display: flex;
flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 18px; gap: 18px;
@ -902,6 +1323,11 @@ td {
padding: 0 0 14px; padding: 0 0 14px;
} }
.object-hero > div:first-child {
flex: 1 1 220px;
min-width: 0;
}
.object-hero p { .object-hero p {
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: #53657f; color: #53657f;
@ -910,9 +1336,12 @@ td {
.object-actions { .object-actions {
display: flex; display: flex;
flex: 0 1 auto;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
max-width: 100%;
min-width: 0;
} }
.object-meta-grid { .object-meta-grid {
@ -948,6 +1377,7 @@ td {
.tabs { .tabs {
display: flex; display: flex;
gap: 20px; gap: 20px;
overflow-x: auto;
margin-top: 12px; margin-top: 12px;
border: 0; border: 0;
border-bottom: 1px solid #dbe4f0; border-bottom: 1px solid #dbe4f0;
@ -1066,6 +1496,11 @@ td {
white-space: pre-wrap; white-space: pre-wrap;
} }
.object-browser table,
.objects-live-panel table {
min-width: 760px;
}
.explain-box { .explain-box {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1117,13 +1552,27 @@ td {
} }
.dashboard-grid { .dashboard-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.repo-browser-grid { .repo-browser-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.repo-browser-grid.repo-panel-collapsed,
.repo-browser-grid.pp-panel-collapsed,
.repo-browser-grid.repo-panel-collapsed.pp-panel-collapsed {
grid-template-columns: 1fr;
}
.collapsed-panel-summary {
min-height: 88px;
}
.collapsed-panel-summary strong {
writing-mode: horizontal-tb;
}
.issue-panel { .issue-panel {
grid-column: span 2; grid-column: span 2;
} }
@ -1132,7 +1581,7 @@ td {
@media (max-width: 1180px) { @media (max-width: 1180px) {
.dashboard-grid, .dashboard-grid,
.object-layout { .object-layout {
grid-template-columns: 1fr; grid-template-columns: minmax(0, 1fr);
} }
.wide-panel, .wide-panel,
@ -1151,9 +1600,47 @@ td {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.app-shell.sidebar-collapsed {
grid-template-columns: 1fr;
}
.sidebar { .sidebar {
position: static; position: static;
height: auto; height: auto;
overflow: visible;
}
.sidebar-collapsed .sidebar {
padding: 22px 14px;
}
.sidebar-collapsed .brand {
justify-content: flex-start;
margin-bottom: 30px;
padding: 0 10px;
}
.sidebar-collapsed .brand-copy,
.sidebar-collapsed .nav-item span,
.sidebar-collapsed .sidebar-toggle span {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
}
.sidebar-collapsed .sidebar-toggle,
.sidebar-collapsed .nav-item {
justify-content: flex-start;
padding-inline: 12px;
}
.sidebar-collapsed .sidebar-toggle {
width: 100%;
height: auto;
margin-inline: 0;
} }
.topbar, .topbar,
@ -1163,6 +1650,55 @@ td {
flex-direction: column; flex-direction: column;
} }
.page-stack {
padding: 14px;
}
.topbar {
gap: 14px;
padding: 14px;
}
.run-selector,
.search-box,
.ready-pill,
.hero-status {
width: 100%;
min-width: 0;
}
.ready-pill {
justify-content: center;
}
.object-layout {
min-height: auto;
}
.object-detail-only-layout {
min-height: auto;
padding: 14px;
}
.object-detail-toolbar {
align-items: stretch;
flex-direction: column;
}
.object-detail-toolbar .search-box,
.object-detail-toolbar .filter-pill {
width: 100%;
max-width: none;
}
.object-detail-card {
margin: 14px;
}
.object-detail-card-expanded {
margin: 0;
}
.metric-grid, .metric-grid,
.object-meta-grid, .object-meta-grid,
.chain-flow { .chain-flow {

View File

@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
test.setTimeout(120_000);
const screenshotRoot = "../../../../specs/develop/20260623_2/m7_copyable_values_pagination_playwright";
async function openRepositoryBranch(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByRole("button", { name: /Repositories/i }).click();
const reposSection = page.locator('section[aria-label="Repositories"]');
await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
await reposSection.locator(".stack-row-select").first().click();
const ppSection = page.locator('section[aria-label="Publication points"]');
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
await ppSection.locator(".stack-row-select").first().click();
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
return objectsPanel;
}
test("long repository, publication point, URI, and hash values expose copy and full hover text", async ({ context, page }) => {
await context.grantPermissions(["clipboard-write"], { origin: "http://127.0.0.1:5173" });
const objectsPanel = await openRepositoryBranch(page);
await expect(page.getByRole("button", { name: "Copy repository URI" }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Copy publication point URI" }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Copy manifest URI" }).first()).toBeVisible();
await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible();
await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible();
const objectUri = objectsPanel.locator(".uri-cell .copyable-value-text").first();
const objectUriText = await objectUri.innerText();
await objectUri.hover();
await expect(page.locator(".copyable-tooltip")).toContainText(objectUriText);
await objectsPanel.getByRole("button", { name: "Copy object URI" }).first().click();
await expect(objectsPanel.locator(".copy-state-text").first()).toHaveText("Copied");
await objectUri.hover();
await page.screenshot({ path: `${screenshotRoot}/repository-copyable-tooltip.png`, fullPage: true });
});
test("object detail keeps copy controls without extra object list panels", async ({ page }) => {
const objectsPanel = await openRepositoryBranch(page);
await objectsPanel.getByRole("button", { name: "Open" }).first().click();
await expect(page.getByText("Object detail · live query service")).toBeVisible({ timeout: 70_000 });
await expect.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0);
await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible();
const objectHash = page.locator(".object-meta-grid").getByText(/^[a-f0-9]{64}$/).first();
const objectHashText = await objectHash.innerText();
await objectHash.hover();
await expect(page.locator(".copyable-tooltip")).toContainText(objectHashText.toLowerCase());
await page.screenshot({ path: `${screenshotRoot}/object-detail-copyable-only.png`, fullPage: true });
});

View File

@ -1,6 +1,22 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test.setTimeout(120_000); test.setTimeout(120_000);
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
async function openFirstRepositoryObject(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByRole("button", { name: /Repositories/i }).click();
const reposSection = page.locator('section[aria-label="Repositories"]');
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
await reposSection.locator(".stack-row-select").first().click();
await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click();
const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first();
await expect(row).toBeVisible({ timeout: 70_000 });
const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim();
await row.getByRole("button", { name: "Open" }).click();
await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 });
return expectedUri;
}
test("renders live object detail and lazy tab requests", async ({ page }) => { test("renders live object detail and lazy tab requests", async ({ page }) => {
const apiRequests: string[] = []; const apiRequests: string[] = [];
@ -18,33 +34,32 @@ test("renders live object detail and lazy tab requests", async ({ page }) => {
} }
}); });
await page.goto("/"); await openFirstRepositoryObject(page);
await page.getByRole("button", { name: /Objects/i }).click(); await expect(page.getByText("Object detail · live query service")).toBeVisible();
await expect(page.getByText("File and chain checks")).toBeVisible();
await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 });
await expect(page.getByText("5A179648B3EF2369DCE7BDB58140FF7DC7060ABF.mft").first()).toBeVisible();
await expect(page.getByText("Final status: valid")).toBeVisible();
const objectDetail = page.getByRole("complementary", { name: "Live object detail" }); const objectDetail = page.getByRole("complementary", { name: "Live object detail" });
await expect(objectDetail.getByText("Authoritative", { exact: true })).toBeVisible(); await expect(objectDetail.getByText("Authoritative", { exact: true })).toBeVisible();
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/parsed"))).toBe(false); await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0);
await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible();
await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible();
expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(false);
await page.getByRole("tab", { name: "Parsed" }).click(); await page.getByRole("tab", { name: "Parsed" }).click();
await expect(page.getByText("Projection unavailable")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/Projection JSON|Projection unavailable/)).toBeVisible({ timeout: 30_000 });
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/parsed"))).toBe(true); expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(true);
await page.getByRole("tab", { name: "Chain" }).click(); await page.getByRole("tab", { name: "Chain" }).click();
await expect(page.getByText("No chain edges recorded for this object.")).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "object-tab-chain");
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/chain"))).toBe(true); expect(apiRequests.some((request) => request.includes("/chain"))).toBe(true);
await page.getByRole("tab", { name: "Validation" }).click(); await page.getByRole("tab", { name: "Validation" }).click();
await page.getByRole("button", { name: "Explain validation" }).click(); await page.getByRole("button", { name: "Explain validation" }).click();
await expect(page.getByText("audit_projection")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/audit_projection|cached audit projection|Explain failed/)).toBeVisible({ timeout: 30_000 });
await expect(objectDetail.getByText("false").last()).toBeVisible();
expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true); expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true);
await page.screenshot({ await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-object-detail-live.png`, fullPage: true });
path: "../../../../specs/develop/20260617/m5_playwright/rpki-explorer-object-detail-live.png",
fullPage: true
});
expect(consoleErrors).toEqual([]); expect(consoleErrors).toEqual([]);
}); });

View File

@ -1,13 +1,15 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
test("renders live overview data from query service without global object list", async ({ page }) => { test("renders live overview data from query service without global object list", async ({ page }) => {
const apiRequests: string[] = []; const apiRequests: string[] = [];
const consoleErrors: string[] = []; const consoleErrors: string[] = [];
page.on("request", (request) => { page.on("request", (request) => {
const path = new URL(request.url()).pathname; const url = new URL(request.url());
if (path.startsWith("/api/v1")) { if (url.pathname.startsWith("/api/v1")) {
apiRequests.push(path); apiRequests.push(`${url.pathname}${url.search}`);
} }
}); });
page.on("console", (message) => { page.on("console", (message) => {
@ -18,22 +20,18 @@ test("renders live overview data from query service without global object list",
await page.goto("/"); await page.goto("/");
await expect(page.getByText("run 7144")).toBeVisible(); await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
await expect(page.getByText("963,779")).toBeVisible(); await expect(page.getByText(/run_\d+/).first()).toBeVisible();
await expect(page.getByText("534,760")).toBeVisible();
await expect(page.getByText("Top repositories by workload")).toBeVisible(); await expect(page.getByText("Top repositories by workload")).toBeVisible();
await expect(page.locator(".issue-card").first().getByText("Reject")).toBeVisible(); await expect(page.locator(".issue-card").first()).toBeVisible();
expect(apiRequests).toContain("/api/v1/latest_run"); expect(apiRequests).toContain("/api/v1/latest_run");
expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/object-types"); expect(apiRequests.some((request) => request.includes("/stats/object-types"))).toBe(true);
expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/validation"); expect(apiRequests.some((request) => request.includes("/stats/validation"))).toBe(true);
expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/reasons"); expect(apiRequests.some((request) => request.includes("/stats/reasons"))).toBe(true);
expect(apiRequests).toContain("/api/v1/runs/run_7144/repos"); expect(apiRequests.some((request) => request.includes("/repos?limit=8"))).toBe(true);
expect(apiRequests).not.toContain("/api/v1/runs/run_7144/objects"); expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
expect(consoleErrors).toEqual([]); expect(consoleErrors).toEqual([]);
await page.screenshot({ await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview-live.png`, fullPage: true });
path: "../../../../specs/develop/20260617/m3_playwright/rpki-explorer-overview-live.png",
fullPage: true
});
}); });

View File

@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test.setTimeout(120_000); test.setTimeout(120_000);
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
test("loads repository, publication point, and object data on demand", async ({ page }) => { test("loads repository, publication point, and object data on demand", async ({ page }) => {
const apiRequests: string[] = []; const apiRequests: string[] = [];
@ -24,28 +25,71 @@ test("loads repository, publication point, and object data on demand", async ({
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
await expect(page.getByText("Select a repository to load its publication points.")).toBeVisible(); await expect(page.getByText("Select a repository to load its publication points.")).toBeVisible();
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(false); expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(false);
expect(apiRequests.some((request) => request.includes("/objects"))).toBe(false); expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
await page.getByRole("button", { name: /sakuya\.nat\.moe/i }).click(); const reposSection = page.locator('section[aria-label="Repositories"]');
await expect(page.getByRole("button", { name: /5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/i })).toBeVisible({ timeout: 30_000 }); await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
expect(apiRequests.some((request) => request.includes("/repos/0490c1fe6e4d4ae5cc354948/publication-points"))).toBe(true); await expect(reposSection.getByLabel("Cursor pagination")).toContainText("Page 1");
expect(apiRequests.some((request) => request.includes("/objects"))).toBe(false); await expect(reposSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/.+ shown/);
await reposSection.getByRole("button", { name: "Collapse repositories column" }).click();
await expect(reposSection.locator(".collapsed-panel-summary")).toContainText("Repositories");
await expect(reposSection.getByRole("button", { name: "Expand repositories column" })).toBeVisible();
await reposSection.getByRole("button", { name: "Expand repositories column" }).click();
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
await expect(reposSection.locator(".stack-row")).toHaveCount(1);
await reposSection.locator(".stack-row-select").first().click();
await page.getByRole("button", { name: /5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/i }).click(); const ppSection = page.locator('section[aria-label="Publication points"]');
await expect.poll( await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
() => apiRequests.some((request) => request.includes("/publication-points/node_10342/objects")), await expect(ppSection.getByLabel("Cursor pagination")).toContainText("Page 1");
{ timeout: 70_000 } await expect(ppSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
).toBe(true); await ppSection.getByRole("button", { name: "Collapse publication points column" }).click();
await expect(ppSection.getByRole("button", { name: "Expand publication points column" })).toBeVisible();
await ppSection.getByRole("button", { name: "Expand publication points column" }).click();
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(true);
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
await ppSection.locator(".stack-row-select").first().click();
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" }); const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
await expect( await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
objectsPanel.getByText(/rsync:\/\/sakuya\.nat\.moe\/repo\/NATOCA\/1\/5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/) expect(apiRequests.some((request) => request.includes("/publication-points/") && request.includes("/objects?limit=50"))).toBe(true);
).toBeVisible({ timeout: 70_000 }); await expect(objectsPanel.getByRole("button", { name: "Open" }).first()).toBeVisible();
await expect(objectsPanel.getByText("manifest").first()).toBeVisible(); await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible();
await expect(page.getByText("limit=50")).toBeVisible(); await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible();
await expect(objectsPanel.getByLabel("Filter objects on current page")).toBeVisible();
await expect(objectsPanel.getByLabel("Cursor pagination")).toBeVisible();
await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText("Page 1");
await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
expect(consoleErrors).toEqual([]); expect(consoleErrors).toEqual([]);
await page.screenshot({ await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-repository-browser.png`, fullPage: true });
path: "../../../../specs/develop/20260617/m4_playwright/rpki-explorer-repository-browser.png", });
fullPage: true
}); test("keeps selected repository and publication point after opening object detail", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: /Repositories/i }).click();
const reposSection = page.locator('section[aria-label="Repositories"]');
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
await expect(reposSection.locator(".stack-row")).toHaveCount(1, { timeout: 30_000 });
const selectedRepoUri = (await reposSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim();
await reposSection.locator(".stack-row-select").first().click();
const ppSection = page.locator('section[aria-label="Publication points"]');
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
const selectedPpUri = (await ppSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim();
await ppSection.locator(".stack-row-select").first().click();
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
const selectedObjectUri = (await objectsPanel.locator(".uri-cell .copyable-value-text").first().innerText()).trim();
await objectsPanel.getByRole("button", { name: "Open" }).first().click();
await expect(page.locator(".object-detail-card")).toContainText(selectedObjectUri, { timeout: 70_000 });
await page.getByRole("button", { name: /Repositories/i }).click();
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
await expect(reposSection.getByLabel("Filter repositories on current page")).toHaveValue("sakuya");
await expect(reposSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedRepoUri);
await expect(ppSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedPpUri);
await expect(objectsPanel.locator("tbody tr").first()).toContainText(selectedObjectUri);
}); });

View File

@ -1,6 +1,8 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("opens the RPKI Explorer overview prototype", async ({ page }) => { const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
function collectConsoleErrors(page: import("@playwright/test").Page) {
const consoleErrors: string[] = []; const consoleErrors: string[] = [];
page.on("console", (message) => { page.on("console", (message) => {
if (message.type() === "error") { if (message.type() === "error") {
@ -10,47 +12,72 @@ test("opens the RPKI Explorer overview prototype", async ({ page }) => {
} }
} }
}); });
return consoleErrors;
}
test("opens the RPKI Explorer overview", async ({ page }) => {
const consoleErrors = collectConsoleErrors(page);
await page.goto("/"); await page.goto("/");
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible(); await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
await expect(page.getByRole("button", { name: /Overview/i })).toBeVisible(); await expect(page.getByRole("button", { name: /Overview/i })).toBeVisible();
await expect(page.getByLabel("Current run")).toContainText("latest indexed run");
await expect(page.getByPlaceholder("Exact URI lookup planned in M2")).toBeDisabled();
await expect(page.getByText("Top repositories by workload")).toBeVisible(); await expect(page.getByText("Top repositories by workload")).toBeVisible();
await expect(page.getByText("Recent validation issues")).toBeVisible(); await expect(page.getByText("Recent validation issues")).toBeVisible();
await expect(page.getByRole("cell", { name: /RRDP|RSYNC/i }).first()).toBeVisible(); await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview.png`, fullPage: true });
await page.screenshot({
path: "../../../../specs/develop/20260617/m2_playwright/rpki-explorer-overview.png",
fullPage: true
});
expect(consoleErrors).toEqual([]); expect(consoleErrors).toEqual([]);
}); });
test("opens the RPKI Explorer object detail prototype", async ({ page }) => { test("collapses sidebar to icon-only navigation on desktop", async ({ page }) => {
test.setTimeout(90_000); const consoleErrors = collectConsoleErrors(page);
const consoleErrors: string[] = [];
page.on("console", (message) => { await page.goto("/");
if (message.type() === "error") { await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
const text = message.text();
if (!text.includes("Failed to load resource")) { await page.getByRole("button", { name: "Collapse navigation" }).click();
consoleErrors.push(text); await expect(page.locator(".app-shell")).toHaveClass(/sidebar-collapsed/);
await expect(page.getByRole("button", { name: "Expand navigation" })).toBeVisible();
await expect(page.locator(".brand-copy")).toHaveCSS("position", "absolute");
await expect(page.locator(".nav-item").first().locator("span")).toHaveCSS("position", "absolute");
await expect(page.getByRole("button", { name: "Repositories" })).toBeVisible();
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-sidebar-collapsed.png`, fullPage: true });
await page.getByRole("button", { name: "Repositories" }).click();
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
await page.getByRole("button", { name: "Expand navigation" }).click();
await expect(page.locator(".app-shell")).not.toHaveClass(/sidebar-collapsed/);
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
expect(consoleErrors).toEqual([]);
});
test("opens truthful placeholders for unfinished navigation", async ({ page }) => {
const consoleErrors = collectConsoleErrors(page);
await page.goto("/");
for (const name of ["Publication Points", "Validation", "Exports", "Runs", "API"]) {
await page.getByRole("button", { name }).click();
await expect(page.locator("#coming-soon-heading")).toHaveText(name);
await expect(page.getByText("Coming soon", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toHaveCount(0);
} }
}
}); await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-coming-soon.png`, fullPage: true });
expect(consoleErrors).toEqual([]);
});
test("objects page starts from explicit empty state", async ({ page }) => {
const consoleErrors = collectConsoleErrors(page);
await page.goto("/"); await page.goto("/");
await page.getByRole("button", { name: /Objects/i }).click(); await page.getByRole("button", { name: /Objects/i }).click();
await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 }); await expect(page.getByRole("heading", { name: "Object object" })).toBeVisible();
await expect(page.getByText("Object detail · live query service")).toBeVisible(); await expect(page.getByText("Search an exact URI or open an object from Repository Browser.")).toBeVisible();
await expect(page.getByText("File and chain checks")).toBeVisible(); await expect(page.locator("body")).not.toContainText("sample PP");
await expect(page.getByRole("tab", { name: "Validation" })).toBeVisible(); await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-objects-empty.png`, fullPage: true });
await page.getByRole("tab", { name: "Validation" }).click();
await expect(page.getByText("Final status: valid")).toBeVisible();
await page.screenshot({
path: "../../../../specs/develop/20260617/m2_playwright/rpki-explorer-object-detail.png",
fullPage: true
});
expect(consoleErrors).toEqual([]); expect(consoleErrors).toEqual([]);
}); });

View File

@ -1,6 +1,21 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test.setTimeout(120_000); test.setTimeout(120_000);
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
async function openFirstRepositoryObject(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByRole("button", { name: /Repositories/i }).click();
const reposSection = page.locator('section[aria-label="Repositories"]');
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
await reposSection.locator(".stack-row-select").first().click();
await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click();
const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first();
await expect(row).toBeVisible({ timeout: 70_000 });
const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim();
await row.getByRole("button", { name: "Open" }).click();
await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 });
}
test("supports URI search and reports raw/export workflow status", async ({ page }) => { test("supports URI search and reports raw/export workflow status", async ({ page }) => {
const apiRequests: string[] = []; const apiRequests: string[] = [];
@ -11,9 +26,7 @@ test("supports URI search and reports raw/export workflow status", async ({ page
} }
}); });
await page.goto("/"); await openFirstRepositoryObject(page);
await page.getByRole("button", { name: /Objects/i }).click();
await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 });
await page.getByRole("button", { name: "Use selected URI" }).click(); await page.getByRole("button", { name: "Use selected URI" }).click();
await page.getByPlaceholder("Exact URI lookup...").press("Enter"); await page.getByPlaceholder("Exact URI lookup...").press("Enter");
@ -23,18 +36,17 @@ test("supports URI search and reports raw/export workflow status", async ({ page
).toBe(true); ).toBe(true);
await page.getByRole("button", { name: "Download raw" }).click(); await page.getByRole("button", { name: "Download raw" }).click();
await expect(page.getByText("Raw download failed: repo-bytes db is not configured for raw download")).toBeVisible({ timeout: 30_000 }); await expect.poll(
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/raw"))).toBe(true); () => apiRequests.some((request) => request.includes("/raw")),
{ timeout: 30_000 }
).toBe(true);
await page.getByRole("button", { name: "Export object" }).click(); await page.getByRole("button", { name: "Export object" }).click();
await expect(page.getByText("Object export failed: repo-bytes db is required for export jobs")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/Object export job|Object export failed/)).toBeVisible({ timeout: 30_000 });
expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/run_7144/exports"))).toBe(true); expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/") && request.includes("/exports"))).toBe(true);
await page.getByRole("button", { name: "Export selected PP" }).click(); await page.getByRole("button", { name: "Export selected PP" }).click();
await expect(page.getByText("PP export failed: repo-bytes db is required for export jobs")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/PP export job|PP export failed/)).toBeVisible({ timeout: 30_000 });
await page.screenshot({ await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-workflows.png`, fullPage: true });
path: "../../../../specs/develop/20260617/m6_playwright/rpki-explorer-workflows.png",
fullPage: true
});
}); });