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 type { LucideIcon } from "lucide-react";
import { useState } from "react";
import { Shell } from "../components/layout/Shell";
import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage";
import { OverviewPage } from "../features/overview/OverviewPage";
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 = [
{ id: "overview", label: "Overview", icon: Home },
@ -14,22 +18,86 @@ const navigationItems = [
{ id: "exports", label: "Exports", icon: PackageOpen },
{ id: "runs", label: "Runs", icon: Activity },
{ 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() {
const [activeView, setActiveView] = useState("overview");
const [activeView, setActiveView] = useState<ViewId>("overview");
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(null);
const [repositoryBrowserState, setRepositoryBrowserState] = useState(createRepositoryBrowserState);
const openObject = (objectInstanceId: string) => {
setSelectedObjectInstanceId(objectInstanceId);
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 (
<Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}>
{activeView === "objects" ? <ObjectDetailPage initialObjectInstanceId={selectedObjectInstanceId} /> : null}
{activeView === "repositories" ? <RepositoriesPage onOpenObject={openObject} /> : null}
{activeView !== "objects" && activeView !== "repositories" ? <OverviewPage /> : null}
{renderActiveView()}
</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 { BarChart3, ChevronDown, CircleHelp, Search, Sun } from "lucide-react";
import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
import type { PropsWithChildren } from "react";
import { useState } from "react";
export interface NavigationItem {
id: string;
export interface NavigationItem<Id extends string = string> {
id: Id;
label: string;
icon: LucideIcon;
}
interface ShellProps extends PropsWithChildren {
activeView: string;
navigationItems: NavigationItem[];
onNavigate: (id: string) => void;
interface ShellProps<Id extends string> extends PropsWithChildren {
activeView: Id;
navigationItems: Array<NavigationItem<Id>>;
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 (
<div className="app-shell">
<div className={`app-shell ${isSidebarCollapsed ? "sidebar-collapsed" : ""}`}>
<aside className="sidebar" aria-label="Primary navigation">
<div className="brand">
<div className="brand-mark">R</div>
<div>
<div className="brand-copy">
<h1 className="brand-title">RPKI Explorer</h1>
<div className="brand-subtitle">Validation Intelligence</div>
</div>
@ -29,9 +32,11 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
{navigationItems.map((item) => (
<button
aria-current={activeView === item.id ? "page" : undefined}
aria-label={isSidebarCollapsed ? item.label : undefined}
className={`nav-item ${activeView === item.id ? "active" : ""}`}
key={item.id}
onClick={() => onNavigate(item.id)}
title={isSidebarCollapsed ? item.label : undefined}
type="button"
>
<item.icon aria-hidden="true" size={18} />
@ -39,24 +44,31 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
</button>
))}
</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>
<main className="main-panel">
<header className="topbar">
<div className="run-selector" aria-label="Current run">
<div className="run-selector readonly-run" aria-label="Current run">
<span>Run</span>
<strong>latest run</strong>
<ChevronDown aria-hidden="true" size={16} />
<strong>latest indexed run</strong>
</div>
<div className="topbar-actions">
<label className="search-box">
<Search aria-hidden="true" size={18} />
<span className="sr-only">Search URI or hash</span>
<input placeholder="Search URI / hash / ASN / prefix…" />
<span className="sr-only">Exact object URI search is not active yet</span>
<input disabled placeholder="Exact URI lookup planned in M2" />
</label>
<div className="ready-pill"><span /> 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 className="ready-pill"><span /> UI Ready</div>
</div>
</header>
{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 { useEffect, useMemo, useState } from "react";
import { normalizeApiError } from "../../api/client";
import { CopyableValue } from "../../components/common/CopyableValue";
import {
createObjectSetExport,
createPublicationPointExport,
@ -14,7 +15,6 @@ import {
getObjectParsed,
getObjectValidation,
listManifestFiles,
listObjectsForPublicationPoint,
listRevokedCertificates,
type ChainEdgeRecord,
type ObjectInstanceRecord,
@ -22,7 +22,6 @@ import {
type ValidationExplainRecord
} from "../../api/queryService";
const SAMPLE_PP_ID = "node_10342";
const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const;
type ObjectTab = (typeof tabs)[number];
@ -33,30 +32,16 @@ interface ObjectDetailPageProps {
export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) {
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(initialObjectInstanceId ?? null);
const [activeTab, setActiveTab] = useState<ObjectTab>("Validation");
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const [searchUri, setSearchUri] = useState("");
const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun });
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(() => {
if (!selectedObjectInstanceId && objectRows.length > 0) {
const preferred = objectRows.find((item) => item.objectType === "manifest") ?? objectRows[0];
setSelectedObjectInstanceId(preferred.objectInstanceId);
if (initialObjectInstanceId && initialObjectInstanceId !== selectedObjectInstanceId) {
setSelectedObjectInstanceId(initialObjectInstanceId);
setActiveTab("Validation");
}
}, [objectRows, selectedObjectInstanceId]);
}, [initialObjectInstanceId, selectedObjectInstanceId]);
const objectQuery = useQuery({
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
@ -64,6 +49,7 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""),
staleTime: 5 * 60 * 1000
});
const selectedObject = objectQuery.data?.data ?? null;
const validationQuery = useQuery({
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
queryKey: ["object-detail-validation", runId, selectedObjectInstanceId],
@ -113,44 +99,43 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : [])
});
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 apiError = latestRunQuery.error ?? objectsQuery.error ?? objectQuery.error ?? validationQuery.error;
const apiError = latestRunQuery.error ?? objectQuery.error ?? validationQuery.error;
return (
<section className="page-stack object-page" aria-labelledby="object-detail-heading">
{apiError ? <ErrorBanner error={apiError} /> : null}
<div className="object-layout live-object-layout">
<aside className="repository-tree" aria-label="Publication point object list">
<p className="eyebrow">Live object list</p>
<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">
<div className="object-detail-only-layout">
<aside className="object-detail-card object-detail-card-expanded" aria-label="Live object detail">
<div className="object-detail-toolbar">
<form
className="search-box object-uri-search"
onSubmit={(event) => {
@ -168,53 +153,30 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
value={searchUri}
/>
</form>
<button className="filter-pill" type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button>
<strong>{objectRows.length} objects</strong>
<button className="filter-pill" disabled={!selectedObject?.uri} type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button>
<strong>{selectedObject ? `${labelObjectType(selectedObject.objectType)} selected` : "No object selected"}</strong>
</div>
<table>
<thead>
<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">
{!selectedObjectInstanceId ? <div className="empty-state">Search an exact URI or open an object from Repository Browser.</div> : null}
{selectedObjectInstanceId && objectQuery.isFetching ? <LoadingLine label="Loading selected object..." /> : null}
<div className="object-hero">
<div>
<p className="eyebrow">Object detail · live query service</p>
<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 className="object-actions">
<StatusPill state={validation?.finalStatus ?? selectedObject?.result ?? "loading"} />
<button className="ghost-button" type="button">
<Copy size={16} /> Copy URI
<button className="ghost-button" disabled={!selectedObject?.uri} onClick={copySelectedUri} type="button">
<Copy size={16} /> {copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : "Copy URI"}
</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
</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
</button>
</div>
@ -232,19 +194,32 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
searchPending={uriSearchMutation.isPending}
/>
<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
</button>
</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) => (
<button
aria-controls={`object-tabpanel-${tabId(tab)}`}
aria-selected={activeTab === tab}
className={activeTab === tab ? "active" : ""}
id={`object-tab-${tabId(tab)}`}
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
tabIndex={activeTab === tab ? 0 : -1}
type="button"
>
{tab}
@ -252,7 +227,12 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
))}
</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 === "Validation" ? (
<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>
</aside>
</div>
</div>
</section>
);
}
@ -271,10 +250,10 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) {
return (
<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="Repository" value={object.repoId} />
<Meta label="Publication Point" value={object.ppId} />
<Meta label="Repository" value={object.repoId} copyable />
<Meta label="Publication Point" value={object.ppId} copyable />
<Meta label="Audit Result" value={validation?.auditResult ?? object.result} />
<Meta label="Authoritative" value={validation ? String(validation.authoritative) : "loading"} />
</div>
@ -398,7 +377,7 @@ function ChainPanel({ edges, isFetching, error }: { edges: ChainEdgeRecord[]; is
<div className="chain-node" key={`${edge.relation}-${edge.toUri}`}>
<strong>{edge.relation}</strong>
<span>{edge.status}</span>
<code>{edge.toUri}</code>
<code><CopyableValue label="chain edge URI" value={edge.toUri} /></code>
</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 (
<div className="meta-item">
<span>{label}</span>
<strong>{value}</strong>
<strong>{copyable ? <CopyableValue label={label} value={value} /> : value}</strong>
</div>
);
}
@ -518,8 +497,8 @@ function labelObjectType(type: string) {
return labels[type] ?? type.toUpperCase();
}
function shortName(uri: string) {
return uri.split("/").filter(Boolean).at(-1) ?? uri;
function tabId(tab: ObjectTab) {
return tab.toLowerCase().replaceAll(" ", "-");
}
function issuesText(issues: { summary?: string }[] | undefined) {

View File

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

View File

@ -1,51 +1,101 @@
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Database, FileCode2, GitBranch, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { ChevronRight, Database, FileCode2, GitBranch, Loader2, PanelLeftClose, PanelRightClose } from "lucide-react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useMemo } from "react";
import { CopyableValue } from "../../components/common/CopyableValue";
import { CursorPager } from "../../components/common/CursorPager";
import { advancePage, previousPage, type PageState } from "../../components/common/cursorPaging";
import { createEmptyPageState, type RepositoryBrowserState } from "./repositoryBrowserState";
import {
getLatestRun,
listObjectsForPublicationPoint,
listPublicationPointsForRepo,
listRepos,
type ObjectInstanceRecord
type ObjectInstanceRecord,
type PublicationPointRecord,
type RepositoryRecord
} from "../../api/queryService";
import { normalizeApiError } from "../../api/client";
const REPO_PAGE_SIZE = 50;
const PP_PAGE_SIZE = 50;
const OBJECT_PAGE_SIZE = 50;
interface RepositoriesPageProps {
browserState: RepositoryBrowserState;
onBrowserStateChange: Dispatch<SetStateAction<RepositoryBrowserState>>;
onOpenObject?: (objectInstanceId: string) => void;
}
export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) {
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
const [selectedPpId, setSelectedPpId] = useState<string | null>(null);
export function RepositoriesPage({ browserState, onBrowserStateChange, onOpenObject }: RepositoriesPageProps) {
const {
selectedRepoId,
selectedPpId,
repoFilter,
ppFilter,
objectFilter,
repoPage,
ppPage,
objectPage,
isRepoPanelCollapsed,
isPpPanelCollapsed
} = browserState;
const setRepoFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, repoFilter: value }));
const setPpFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, ppFilter: value }));
const setObjectFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, objectFilter: value }));
const setRepoPage = (updater: SetStateAction<PageState>) => {
onBrowserStateChange((state) => ({
...state,
repoPage: typeof updater === "function" ? updater(state.repoPage) : updater
}));
};
const setPpPage = (updater: SetStateAction<PageState>) => {
onBrowserStateChange((state) => ({
...state,
ppPage: typeof updater === "function" ? updater(state.ppPage) : updater
}));
};
const setObjectPage = (updater: SetStateAction<PageState>) => {
onBrowserStateChange((state) => ({
...state,
objectPage: typeof updater === "function" ? updater(state.objectPage) : updater
}));
};
const latestRunQuery = useQuery({
queryKey: ["repositories-latest-run"],
queryFn: getLatestRun
});
const runId = latestRunQuery.data?.data.runId ?? "latest";
const reposQuery = useQuery({
queryKey: ["repositories", runId],
queryFn: () => listRepos(runId, 50),
queryKey: ["repositories", runId, repoPage.cursor],
queryFn: () => listRepos(runId, REPO_PAGE_SIZE, repoPage.cursor),
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 ppsQuery = useQuery({
queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? ""],
queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", 50),
queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? "", ppPage.cursor],
queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", PP_PAGE_SIZE, ppPage.cursor),
enabled: Boolean(selectedRepo?.repoId)
});
const publicationPoints = ppsQuery.data?.data ?? [];
const publicationPoints = useMemo(() => ppsQuery.data?.data ?? [], [ppsQuery.data?.data]);
const filteredPublicationPoints = useMemo(() => filterPublicationPoints(publicationPoints, ppFilter), [ppFilter, publicationPoints]);
const selectedPp = selectedPpId
? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null
: null;
const objectsQuery = useQuery({
queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? ""],
queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", 50),
queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? "", objectPage.cursor],
queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", OBJECT_PAGE_SIZE, objectPage.cursor),
enabled: Boolean(selectedPp?.ppId)
});
const objects = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]);
const filteredObjects = useMemo(() => filterObjects(objects, objectFilter), [objectFilter, objects]);
const repoTotal = reposQuery.data?.page?.nextCursor ? undefined : repoPage.previous.length * REPO_PAGE_SIZE + repos.length;
const ppTotal = selectedRepo?.publicationPoints;
const objectTotal = selectedPp?.objects;
const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error;
@ -73,94 +123,236 @@ export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) {
</div>
) : null}
<div className="repo-browser-grid">
<section className="panel list-panel" aria-label="Repositories">
<PanelTitle icon={<Database size={18} />} label="Repositories" meta={reposQuery.isFetching ? "loading" : `${repos.length} shown`} />
<div className={`repo-browser-grid ${isRepoPanelCollapsed ? "repo-panel-collapsed" : ""} ${isPpPanelCollapsed ? "pp-panel-collapsed" : ""}`}>
<section className="panel list-panel collapsible-list-panel repo-list-panel" aria-label="Repositories">
<PanelTitle
icon={<Database size={18} />}
isCollapsed={isRepoPanelCollapsed}
label="Repositories"
meta={reposQuery.isFetching ? "loading" : rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isRepoPanelCollapsed: !state.isRepoPanelCollapsed }))}
toggleLabel={isRepoPanelCollapsed ? "Expand repositories column" : "Collapse repositories column"}
/>
{isRepoPanelCollapsed ? (
<CollapsedPanelSummary icon={<Database size={20} />} label="Repositories" value={repoTotal ?? repos.length} />
) : (
<>
<CurrentPageFilter
label="Filter repositories on current page"
onChange={setRepoFilter}
placeholder="Filter host or URI..."
value={repoFilter}
/>
<div className="stack-list">
{repos.map((repo) => (
{filteredRepos.map((repo) => (
<article className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`} key={repo.repoId}>
<button
className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`}
key={repo.repoId}
className="stack-row-select"
onClick={() => {
setSelectedRepoId(repo.repoId);
setSelectedPpId(null);
onBrowserStateChange((state) => ({
...state,
selectedRepoId: repo.repoId,
selectedPpId: null,
ppFilter: "",
objectFilter: "",
ppPage: createEmptyPageState(),
objectPage: createEmptyPageState()
}));
}}
type="button"
>
<span>
<strong>{repo.host}</strong>
<small>{repo.uri}</small>
<small>{repo.transport.toUpperCase()} · {repo.publicationPoints.toLocaleString()} publication points</small>
</span>
<em>{repo.objects.toLocaleString()}</em>
<ChevronRight size={16} />
</button>
<CopyableValue className="stack-row-copy" label="repository URI" value={repo.uri} />
</article>
))}
</div>
<CursorPager
isFetching={reposQuery.isFetching}
pageNumber={repoPage.previous.length + 1}
nextCursor={reposQuery.data?.page?.nextCursor ?? null}
onNext={() => setRepoPage((page) => advancePage(page, reposQuery.data?.page?.nextCursor ?? null))}
onPrevious={() => setRepoPage(previousPage)}
previousCount={repoPage.previous.length}
rangeLabel={rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
/>
</>
)}
</section>
<section className="panel list-panel" aria-label="Publication points">
<section className="panel list-panel collapsible-list-panel pp-list-panel" aria-label="Publication points">
<PanelTitle
icon={<GitBranch size={18} />}
isCollapsed={isPpPanelCollapsed}
label="Publication Points"
meta={ppsQuery.isFetching ? "loading" : `${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 ? (
<div className="empty-state">Select a repository to load its publication points.</div>
) : null}
<CurrentPageFilter
disabled={!selectedRepo}
label="Filter publication points on current page"
onChange={setPpFilter}
placeholder="Filter manifest, rsync, source..."
value={ppFilter}
/>
{ppsQuery.isFetching ? <LoadingLine /> : null}
<div className="stack-list">
{publicationPoints.map((pp) => (
{filteredPublicationPoints.map((pp) => (
<article className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`} key={pp.ppId}>
<button
className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`}
key={pp.ppId}
onClick={() => setSelectedPpId(pp.ppId)}
className="stack-row-select"
onClick={() => {
onBrowserStateChange((state) => ({
...state,
selectedPpId: pp.ppId,
objectFilter: "",
objectPage: createEmptyPageState()
}));
}}
type="button"
>
<span>
<strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong>
<small>{pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? "unknown"}</small>
<small>{pp.repoSyncSource ?? pp.source ?? "unknown"} · {pp.ppId}</small>
</span>
<em>{pp.objects.toLocaleString()}</em>
<StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} />
</button>
<CopyableValue className="stack-row-copy" label="publication point URI" value={pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? pp.ppId} />
{pp.manifestRsyncUri ? <CopyableValue className="stack-row-copy secondary" label="manifest URI" value={pp.manifestRsyncUri} /> : null}
</article>
))}
</div>
<CursorPager
isFetching={ppsQuery.isFetching}
nextCursor={ppsQuery.data?.page?.nextCursor ?? null}
onNext={() => setPpPage((page) => advancePage(page, ppsQuery.data?.page?.nextCursor ?? null))}
onPrevious={() => setPpPage(previousPage)}
pageNumber={ppPage.previous.length + 1}
previousCount={ppPage.previous.length}
rangeLabel={selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : undefined}
/>
</>
)}
</section>
<section className="panel table-panel objects-live-panel" aria-label="Objects for publication point">
<PanelTitle
icon={<FileCode2 size={18} />}
label="Objects"
meta={objectsQuery.isFetching ? "loading" : `${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 ? (
<div className="empty-state">Select a publication point to load its objects.</div>
) : null}
<CurrentPageFilter
disabled={!selectedPp}
label="Filter objects on current page"
onChange={setObjectFilter}
placeholder="Filter type, URI, hash, status..."
value={objectFilter}
/>
{objectsQuery.isFetching ? <LoadingLine /> : null}
{selectedPp && !objectsQuery.isFetching ? (
<ObjectTable objects={objectsQuery.data?.data ?? []} onOpenObject={onOpenObject} />
<ObjectTable objects={filteredObjects} onOpenObject={onOpenObject} />
) : null}
<div className="pagination-note">
Default query uses <code>limit=50</code>. Next cursor: <code>{objectsQuery.data?.page?.nextCursor ?? "none"}</code>
</div>
<CursorPager
isFetching={objectsQuery.isFetching}
nextCursor={objectsQuery.data?.page?.nextCursor ?? null}
onNext={() => setObjectPage((page) => advancePage(page, objectsQuery.data?.page?.nextCursor ?? null))}
onPrevious={() => setObjectPage(previousPage)}
pageNumber={objectPage.previous.length + 1}
previousCount={objectPage.previous.length}
rangeLabel={selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : undefined}
/>
</section>
</div>
</section>
);
}
function PanelTitle({ icon, 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 (
<div className="panel-heading compact-heading">
<div>
<p className="eyebrow">{label}</p>
<h3>{label}</h3>
</div>
<div className="panel-heading-actions">
<span className="panel-meta">
{icon}
{meta}
</span>
{onToggleCollapse ? (
<button aria-expanded={!isCollapsed} aria-label={toggleLabel} className="panel-collapse-button" onClick={onToggleCollapse} title={toggleLabel} type="button">
{isCollapsed ? <PanelRightClose aria-hidden="true" size={16} /> : <PanelLeftClose aria-hidden="true" size={16} />}
</button>
) : null}
</div>
</div>
);
}
function CollapsedPanelSummary({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
return (
<div className="collapsed-panel-summary">
{icon}
<strong>{label}</strong>
<span>{value.toLocaleString()}</span>
</div>
);
}
function CurrentPageFilter({
disabled,
label,
onChange,
placeholder,
value
}: {
disabled?: boolean;
label: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<label className="current-page-filter">
<span>{label}</span>
<input
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
value={value}
/>
</label>
);
}
@ -184,8 +376,8 @@ function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[
{objects.map((object) => (
<tr key={object.objectInstanceId}>
<td><span className="object-type">{object.objectType}</span></td>
<td className="uri-cell">{object.uri}</td>
<td>{object.sha256.slice(0, 12)}...</td>
<td className="uri-cell"><CopyableValue label="object URI" value={object.uri} /></td>
<td><CopyableValue label="object SHA256" value={object.sha256} displayValue={shortHash(object.sha256, 12)} /></td>
<td>{object.sourceSection}</td>
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
<td>
@ -222,3 +414,54 @@ function LoadingLine() {
function shortName(uri: string) {
return uri.split("/").filter(Boolean).at(-1) ?? uri;
}
function shortHash(value: string, prefixLength: number) {
return `${value.slice(0, prefixLength)}...`;
}
function rangeLabel(pageNumber: number, pageSize: number, pageRowCount: number, total: number | undefined, filter: string, visibleRowCount = pageRowCount) {
const start = pageRowCount === 0 ? 0 : (pageNumber - 1) * pageSize + 1;
const end = pageRowCount === 0 ? 0 : start + pageRowCount - 1;
const totalText = total === undefined ? "unknown" : total.toLocaleString();
const filterText = filter.trim() ? ` · ${visibleRowCount.toLocaleString()}/${pageRowCount.toLocaleString()} matched on page` : "";
return `${start.toLocaleString()}-${end.toLocaleString()}/${totalText} shown${filterText}`;
}
function matchesNeedle(values: Array<string | number | null | undefined>, needle: string) {
const normalized = needle.trim().toLowerCase();
if (!normalized) {
return true;
}
return values.some((value) => String(value ?? "").toLowerCase().includes(normalized));
}
function filterRepositories(repos: RepositoryRecord[], filter: string) {
return repos.filter((repo) => matchesNeedle([repo.host, repo.uri, repo.transport, repo.objects, repo.rejectedObjects], filter));
}
function filterPublicationPoints(publicationPoints: PublicationPointRecord[], filter: string) {
return publicationPoints.filter((pp) => matchesNeedle([
pp.ppId,
pp.manifestRsyncUri,
pp.publicationPointRsyncUri,
pp.rsyncBaseUri,
pp.rrdpNotificationUri,
pp.source,
pp.repoSyncSource,
pp.repoTerminalState,
pp.objects,
pp.rejectedObjects
], filter));
}
function filterObjects(objects: ObjectInstanceRecord[], filter: string) {
return objects.filter((object) => matchesNeedle([
object.objectType,
object.uri,
object.sha256,
object.sourceSection,
object.result,
object.rejectReason,
object.rejected ? "rejected" : "accepted"
], filter));
}

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;
min-width: 320px;
min-height: 100vh;
overflow-x: hidden;
background: #f7faff;
}
@ -38,12 +39,20 @@ input:focus-visible {
grid-template-columns: 230px minmax(0, 1fr);
min-height: 100vh;
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 {
position: sticky;
display: flex;
flex-direction: column;
top: 0;
height: 100vh;
overflow: hidden;
border-right: 1px solid #dbe4f0;
background: rgba(255, 255, 255, 0.88);
color: #10213f;
@ -58,7 +67,13 @@ input:focus-visible {
padding: 0 10px;
}
.brand-copy {
min-width: 0;
transition: opacity 160ms ease;
}
.brand-mark {
flex: 0 0 36px;
display: grid;
width: 36px;
height: 36px;
@ -85,6 +100,33 @@ input:focus-visible {
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 {
margin-bottom: 8px;
color: #60718e;
@ -97,6 +139,7 @@ input:focus-visible {
.nav-list {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.nav-item {
@ -110,6 +153,7 @@ input:focus-visible {
background: transparent;
color: #122544;
text-align: left;
transition: background 160ms ease, color 160ms ease;
}
.nav-item.active,
@ -119,6 +163,7 @@ input:focus-visible {
}
.nav-item svg {
flex: 0 0 auto;
color: #24466f;
}
@ -127,12 +172,51 @@ input:focus-visible {
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 {
min-width: 0;
}
.topbar {
display: flex;
min-width: 0;
min-height: 76px;
align-items: center;
justify-content: space-between;
@ -165,6 +249,7 @@ h3 {
display: flex;
align-items: center;
gap: 18px;
max-width: 100%;
min-width: 206px;
border: 1px solid #d2deee;
border-radius: 9px;
@ -185,6 +270,7 @@ h3 {
.topbar-actions {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 14px;
@ -194,6 +280,7 @@ h3 {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
width: min(740px, 100%);
border: 1px solid #d2deee;
border-radius: 9px;
@ -265,6 +352,7 @@ h3 {
.hero-dashboard {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 20px;
@ -287,6 +375,7 @@ h3 {
.hero-status {
display: flex;
max-width: 100%;
min-width: 230px;
align-items: center;
gap: 12px;
@ -391,8 +480,9 @@ h3 {
.dashboard-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;
min-width: 0;
}
.panel,
@ -402,6 +492,7 @@ h3 {
.tabs,
.object-browser,
.object-detail-card {
min-width: 0;
border: 1px solid #dbe4f0;
border-radius: 14px;
background: white;
@ -409,10 +500,15 @@ h3 {
}
.panel {
overflow-x: visible;
min-width: 0;
padding: 18px;
}
.table-panel {
overflow-x: auto;
}
.wide-panel {
grid-column: span 2;
}
@ -438,16 +534,101 @@ h3 {
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 {
display: grid;
grid-template-columns: minmax(300px, 0.85fr) minmax(340px, 1fr) minmax(520px, 1.35fr);
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 {
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 {
display: grid;
gap: 8px;
@ -458,9 +639,8 @@ h3 {
.stack-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
width: 100%;
border: 1px solid #e3ebf5;
border-radius: 10px;
@ -470,27 +650,40 @@ h3 {
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:hover {
border-color: #b7d2fb;
background: #eaf3ff;
}
.stack-row strong,
.stack-row small {
.stack-row-select strong,
.stack-row-select small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stack-row small {
.stack-row-select small {
margin-top: 4px;
color: #60718e;
font-size: 12px;
}
.stack-row em {
.stack-row-select em {
color: #0759d7;
font-size: 12px;
font-style: normal;
@ -498,6 +691,7 @@ h3 {
}
.objects-live-panel {
overflow-x: auto;
min-width: 0;
}
@ -518,6 +712,163 @@ h3 {
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,
.empty-state,
.loading-line {
@ -639,6 +990,7 @@ h3 {
table {
width: 100%;
min-width: 620px;
border-collapse: collapse;
}
@ -693,6 +1045,8 @@ td {
}
.issue-card {
min-width: 0;
max-width: 100%;
border-bottom: 1px solid #e3ebf5;
padding: 14px 0;
}
@ -703,17 +1057,23 @@ td {
.issue-card div {
display: flex;
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.issue-card p {
max-width: 100%;
overflow-wrap: anywhere;
margin: 9px 0;
color: #122544;
}
.issue-card code {
display: block;
max-width: 100%;
overflow: hidden;
color: #53657f;
font-size: 12px;
@ -723,6 +1083,8 @@ td {
.issue-card small {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
margin-top: 7px;
color: #60718e;
}
@ -774,6 +1136,40 @@ td {
grid-template-columns: 248px minmax(350px, 1fr) minmax(330px, 0.9fr);
min-height: calc(100vh - 76px);
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 {
@ -818,17 +1214,27 @@ td {
}
.object-picker {
display: block;
display: grid;
gap: 6px;
width: calc(100% - 18px);
}
.object-picker-select {
display: block;
width: 100%;
border: 0;
background: transparent;
color: inherit;
padding: 0;
text-align: left;
}
.object-picker strong,
.object-picker span {
.object-picker-select strong,
.object-picker-select span {
display: block;
}
.object-picker span {
.object-picker-select span {
overflow: hidden;
margin-top: 4px;
color: #60718e;
@ -842,7 +1248,8 @@ td {
}
.object-browser {
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
border-top: 0;
border-bottom: 0;
border-radius: 0;
@ -851,6 +1258,7 @@ td {
.browser-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
@ -859,6 +1267,7 @@ td {
}
.browser-toolbar .search-box {
flex: 1 1 220px;
max-width: 240px;
padding: 9px 12px;
}
@ -885,14 +1294,26 @@ td {
font-weight: 850;
}
.object-row-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.object-detail-card {
align-self: start;
margin: 14px;
padding: 18px;
}
.object-detail-card.object-detail-card-expanded {
margin: 0 auto;
}
.object-hero {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
@ -902,6 +1323,11 @@ td {
padding: 0 0 14px;
}
.object-hero > div:first-child {
flex: 1 1 220px;
min-width: 0;
}
.object-hero p {
overflow-wrap: anywhere;
color: #53657f;
@ -910,9 +1336,12 @@ td {
.object-actions {
display: flex;
flex: 0 1 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: 100%;
min-width: 0;
}
.object-meta-grid {
@ -948,6 +1377,7 @@ td {
.tabs {
display: flex;
gap: 20px;
overflow-x: auto;
margin-top: 12px;
border: 0;
border-bottom: 1px solid #dbe4f0;
@ -1066,6 +1496,11 @@ td {
white-space: pre-wrap;
}
.object-browser table,
.objects-live-panel table {
min-width: 760px;
}
.explain-box {
display: flex;
flex-wrap: wrap;
@ -1117,13 +1552,27 @@ td {
}
.dashboard-grid {
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.repo-browser-grid {
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 {
grid-column: span 2;
}
@ -1132,7 +1581,7 @@ td {
@media (max-width: 1180px) {
.dashboard-grid,
.object-layout {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
.wide-panel,
@ -1151,9 +1600,47 @@ td {
grid-template-columns: 1fr;
}
.app-shell.sidebar-collapsed {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
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,
@ -1163,6 +1650,55 @@ td {
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,
.object-meta-grid,
.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";
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 }) => {
const apiRequests: string[] = [];
@ -18,33 +34,32 @@ test("renders live object detail and lazy tab requests", async ({ page }) => {
}
});
await page.goto("/");
await page.getByRole("button", { name: /Objects/i }).click();
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();
await openFirstRepositoryObject(page);
await expect(page.getByText("Object detail · live query service")).toBeVisible();
await expect(page.getByText("File and chain checks")).toBeVisible();
const objectDetail = page.getByRole("complementary", { name: "Live object detail" });
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 expect(page.getByText("Projection unavailable")).toBeVisible({ timeout: 30_000 });
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/parsed"))).toBe(true);
await expect(page.getByText(/Projection JSON|Projection unavailable/)).toBeVisible({ timeout: 30_000 });
expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(true);
await page.getByRole("tab", { name: "Chain" }).click();
await expect(page.getByText("No chain edges recorded for this object.")).toBeVisible({ timeout: 30_000 });
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/chain"))).toBe(true);
await expect(page.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "object-tab-chain");
expect(apiRequests.some((request) => request.includes("/chain"))).toBe(true);
await page.getByRole("tab", { name: "Validation" }).click();
await page.getByRole("button", { name: "Explain validation" }).click();
await expect(page.getByText("audit_projection")).toBeVisible({ timeout: 30_000 });
await expect(objectDetail.getByText("false").last()).toBeVisible();
await expect(page.getByText(/audit_projection|cached audit projection|Explain failed/)).toBeVisible({ timeout: 30_000 });
expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true);
await page.screenshot({
path: "../../../../specs/develop/20260617/m5_playwright/rpki-explorer-object-detail-live.png",
fullPage: true
});
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-object-detail-live.png`, fullPage: true });
expect(consoleErrors).toEqual([]);
});

View File

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

View File

@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
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 }) => {
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.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("/objects"))).toBe(false);
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
await page.getByRole("button", { name: /sakuya\.nat\.moe/i }).click();
await expect(page.getByRole("button", { name: /5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/i })).toBeVisible({ timeout: 30_000 });
expect(apiRequests.some((request) => request.includes("/repos/0490c1fe6e4d4ae5cc354948/publication-points"))).toBe(true);
expect(apiRequests.some((request) => request.includes("/objects"))).toBe(false);
const reposSection = page.locator('section[aria-label="Repositories"]');
await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
await expect(reposSection.getByLabel("Cursor pagination")).toContainText("Page 1");
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();
await expect.poll(
() => apiRequests.some((request) => request.includes("/publication-points/node_10342/objects")),
{ timeout: 70_000 }
).toBe(true);
const ppSection = page.locator('section[aria-label="Publication points"]');
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
await expect(ppSection.getByLabel("Cursor pagination")).toContainText("Page 1");
await expect(ppSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
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" });
await expect(
objectsPanel.getByText(/rsync:\/\/sakuya\.nat\.moe\/repo\/NATOCA\/1\/5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/)
).toBeVisible({ timeout: 70_000 });
await expect(objectsPanel.getByText("manifest").first()).toBeVisible();
await expect(page.getByText("limit=50")).toBeVisible();
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
expect(apiRequests.some((request) => request.includes("/publication-points/") && request.includes("/objects?limit=50"))).toBe(true);
await expect(objectsPanel.getByRole("button", { name: "Open" }).first()).toBeVisible();
await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).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([]);
await page.screenshot({
path: "../../../../specs/develop/20260617/m4_playwright/rpki-explorer-repository-browser.png",
fullPage: true
});
await page.screenshot({ path: `${screenshotRoot}/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";
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[] = [];
page.on("console", (message) => {
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 expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).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("Recent validation issues")).toBeVisible();
await expect(page.getByRole("cell", { name: /RRDP|RSYNC/i }).first()).toBeVisible();
await page.screenshot({
path: "../../../../specs/develop/20260617/m2_playwright/rpki-explorer-overview.png",
fullPage: true
});
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview.png`, fullPage: true });
expect(consoleErrors).toEqual([]);
});
test("opens the RPKI Explorer object detail prototype", async ({ page }) => {
test.setTimeout(90_000);
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") {
const text = message.text();
if (!text.includes("Failed to load resource")) {
consoleErrors.push(text);
test("collapses sidebar to icon-only navigation on desktop", async ({ page }) => {
const consoleErrors = collectConsoleErrors(page);
await page.goto("/");
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
await page.getByRole("button", { name: "Collapse navigation" }).click();
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.getByRole("button", { name: /Objects/i }).click();
await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 });
await expect(page.getByText("Object detail · live query service")).toBeVisible();
await expect(page.getByText("File and chain checks")).toBeVisible();
await expect(page.getByRole("tab", { name: "Validation" })).toBeVisible();
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
});
await expect(page.getByRole("heading", { name: "Object object" })).toBeVisible();
await expect(page.getByText("Search an exact URI or open an object from Repository Browser.")).toBeVisible();
await expect(page.locator("body")).not.toContainText("sample PP");
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-objects-empty.png`, fullPage: true });
expect(consoleErrors).toEqual([]);
});

View File

@ -1,6 +1,21 @@
import { expect, test } from "@playwright/test";
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 }) => {
const apiRequests: string[] = [];
@ -11,9 +26,7 @@ test("supports URI search and reports raw/export workflow status", async ({ page
}
});
await page.goto("/");
await page.getByRole("button", { name: /Objects/i }).click();
await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 });
await openFirstRepositoryObject(page);
await page.getByRole("button", { name: "Use selected URI" }).click();
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);
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 });
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/raw"))).toBe(true);
await expect.poll(
() => apiRequests.some((request) => request.includes("/raw")),
{ timeout: 30_000 }
).toBe(true);
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 });
expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/run_7144/exports"))).toBe(true);
await expect(page.getByText(/Object export job|Object export failed/)).toBeVisible({ timeout: 30_000 });
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 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({
path: "../../../../specs/develop/20260617/m6_playwright/rpki-explorer-workflows.png",
fullPage: true
});
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-workflows.png`, fullPage: true });
});