20260623 optimize rpki explorer ui interactions
This commit is contained in:
parent
4f41fbe04e
commit
574e40a4d4
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
88
ui/rpki-explorer/src/components/common/CopyableValue.tsx
Normal file
88
ui/rpki-explorer/src/components/common/CopyableValue.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
32
ui/rpki-explorer/src/components/common/CursorPager.tsx
Normal file
32
ui/rpki-explorer/src/components/common/CursorPager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
ui/rpki-explorer/src/components/common/cursorPaging.ts
Normal file
24
ui/rpki-explorer/src/components/common/cursorPaging.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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: [] };
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
62
ui/rpki-explorer/tests/e2e/copyable-values.spec.ts
Normal file
62
ui/rpki-explorer/tests/e2e/copyable-values.spec.ts
Normal 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 });
|
||||
});
|
||||
@ -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([]);
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user