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 { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Shell } from "../components/layout/Shell";
|
import { Shell } from "../components/layout/Shell";
|
||||||
import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage";
|
import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage";
|
||||||
import { OverviewPage } from "../features/overview/OverviewPage";
|
import { OverviewPage } from "../features/overview/OverviewPage";
|
||||||
import { RepositoriesPage } from "../features/repositories/RepositoriesPage";
|
import { RepositoriesPage } from "../features/repositories/RepositoriesPage";
|
||||||
|
import { createRepositoryBrowserState } from "../features/repositories/repositoryBrowserState";
|
||||||
|
|
||||||
|
type ViewId = "overview" | "repositories" | "publication-points" | "objects" | "validation" | "exports" | "runs" | "api";
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ id: "overview", label: "Overview", icon: Home },
|
{ id: "overview", label: "Overview", icon: Home },
|
||||||
@ -14,22 +18,86 @@ const navigationItems = [
|
|||||||
{ id: "exports", label: "Exports", icon: PackageOpen },
|
{ id: "exports", label: "Exports", icon: PackageOpen },
|
||||||
{ id: "runs", label: "Runs", icon: Activity },
|
{ id: "runs", label: "Runs", icon: Activity },
|
||||||
{ id: "api", label: "API", icon: FileSearch }
|
{ id: "api", label: "API", icon: FileSearch }
|
||||||
];
|
] satisfies Array<{ id: ViewId; label: string; icon: LucideIcon }>;
|
||||||
|
|
||||||
|
const comingSoonCopy: Record<Exclude<ViewId, "overview" | "repositories" | "objects">, { title: string; description: string; alternative: string }> = {
|
||||||
|
"publication-points": {
|
||||||
|
title: "Publication Points",
|
||||||
|
description: "Dedicated publication point analytics are not implemented yet.",
|
||||||
|
alternative: "Use Repositories to drill down from repository to publication point and object rows."
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
title: "Validation",
|
||||||
|
description: "A dedicated validation investigation workspace is planned but not available in this MVP.",
|
||||||
|
alternative: "Use Overview for reason summaries and Object Detail for per-object validation evidence."
|
||||||
|
},
|
||||||
|
exports: {
|
||||||
|
title: "Exports",
|
||||||
|
description: "A standalone export job browser is planned but not available yet.",
|
||||||
|
alternative: "Use Object Detail to trigger object or publication point export workflows."
|
||||||
|
},
|
||||||
|
runs: {
|
||||||
|
title: "Runs",
|
||||||
|
description: "Historical run navigation is not implemented in the current Explorer UI.",
|
||||||
|
alternative: "The current view always uses the latest indexed run from query service."
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
title: "API",
|
||||||
|
description: "The API reference page is planned but not implemented yet.",
|
||||||
|
alternative: "Use the project README and query service endpoints while the UI reference page is pending."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeView, setActiveView] = useState("overview");
|
const [activeView, setActiveView] = useState<ViewId>("overview");
|
||||||
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(null);
|
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(null);
|
||||||
|
const [repositoryBrowserState, setRepositoryBrowserState] = useState(createRepositoryBrowserState);
|
||||||
|
|
||||||
const openObject = (objectInstanceId: string) => {
|
const openObject = (objectInstanceId: string) => {
|
||||||
setSelectedObjectInstanceId(objectInstanceId);
|
setSelectedObjectInstanceId(objectInstanceId);
|
||||||
setActiveView("objects");
|
setActiveView("objects");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderActiveView = () => {
|
||||||
|
if (activeView === "objects") {
|
||||||
|
return <ObjectDetailPage initialObjectInstanceId={selectedObjectInstanceId} />;
|
||||||
|
}
|
||||||
|
if (activeView === "repositories") {
|
||||||
|
return (
|
||||||
|
<RepositoriesPage
|
||||||
|
browserState={repositoryBrowserState}
|
||||||
|
onBrowserStateChange={setRepositoryBrowserState}
|
||||||
|
onOpenObject={openObject}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (activeView === "overview") {
|
||||||
|
return <OverviewPage />;
|
||||||
|
}
|
||||||
|
return <ComingSoonPage {...comingSoonCopy[activeView]} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}>
|
<Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}>
|
||||||
{activeView === "objects" ? <ObjectDetailPage initialObjectInstanceId={selectedObjectInstanceId} /> : null}
|
{renderActiveView()}
|
||||||
{activeView === "repositories" ? <RepositoriesPage onOpenObject={openObject} /> : null}
|
|
||||||
{activeView !== "objects" && activeView !== "repositories" ? <OverviewPage /> : null}
|
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ComingSoonPage({ title, description, alternative }: { title: string; description: string; alternative: string }) {
|
||||||
|
return (
|
||||||
|
<section className="page-stack" aria-labelledby="coming-soon-heading">
|
||||||
|
<div className="hero-dashboard compact-hero coming-soon-panel">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Planned workspace</p>
|
||||||
|
<h2 id="coming-soon-heading">{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="hero-status muted-status">
|
||||||
|
<strong>Coming soon</strong>
|
||||||
|
<span>{alternative}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
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 type { LucideIcon } from "lucide-react";
|
||||||
import { BarChart3, ChevronDown, CircleHelp, Search, Sun } from "lucide-react";
|
import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export interface NavigationItem {
|
export interface NavigationItem<Id extends string = string> {
|
||||||
id: string;
|
id: Id;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShellProps extends PropsWithChildren {
|
interface ShellProps<Id extends string> extends PropsWithChildren {
|
||||||
activeView: string;
|
activeView: Id;
|
||||||
navigationItems: NavigationItem[];
|
navigationItems: Array<NavigationItem<Id>>;
|
||||||
onNavigate: (id: string) => void;
|
onNavigate: (id: Id) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ activeView, navigationItems, onNavigate, children }: ShellProps) {
|
export function Shell<Id extends string>({ activeView, navigationItems, onNavigate, children }: ShellProps<Id>) {
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className={`app-shell ${isSidebarCollapsed ? "sidebar-collapsed" : ""}`}>
|
||||||
<aside className="sidebar" aria-label="Primary navigation">
|
<aside className="sidebar" aria-label="Primary navigation">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<div className="brand-mark">R</div>
|
<div className="brand-mark">R</div>
|
||||||
<div>
|
<div className="brand-copy">
|
||||||
<h1 className="brand-title">RPKI Explorer</h1>
|
<h1 className="brand-title">RPKI Explorer</h1>
|
||||||
<div className="brand-subtitle">Validation Intelligence</div>
|
<div className="brand-subtitle">Validation Intelligence</div>
|
||||||
</div>
|
</div>
|
||||||
@ -29,9 +32,11 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
|
|||||||
{navigationItems.map((item) => (
|
{navigationItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
aria-current={activeView === item.id ? "page" : undefined}
|
aria-current={activeView === item.id ? "page" : undefined}
|
||||||
|
aria-label={isSidebarCollapsed ? item.label : undefined}
|
||||||
className={`nav-item ${activeView === item.id ? "active" : ""}`}
|
className={`nav-item ${activeView === item.id ? "active" : ""}`}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onNavigate(item.id)}
|
onClick={() => onNavigate(item.id)}
|
||||||
|
title={isSidebarCollapsed ? item.label : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<item.icon aria-hidden="true" size={18} />
|
<item.icon aria-hidden="true" size={18} />
|
||||||
@ -39,24 +44,31 @@ export function Shell({ activeView, navigationItems, onNavigate, children }: She
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<button
|
||||||
|
aria-expanded={!isSidebarCollapsed}
|
||||||
|
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||||
|
className="sidebar-toggle"
|
||||||
|
onClick={() => setIsSidebarCollapsed((collapsed) => !collapsed)}
|
||||||
|
title={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isSidebarCollapsed ? <PanelLeftOpen aria-hidden="true" size={18} /> : <PanelLeftClose aria-hidden="true" size={18} />}
|
||||||
|
<span>{isSidebarCollapsed ? "Expand" : "Collapse"}</span>
|
||||||
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="main-panel">
|
<main className="main-panel">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="run-selector" aria-label="Current run">
|
<div className="run-selector readonly-run" aria-label="Current run">
|
||||||
<span>Run</span>
|
<span>Run</span>
|
||||||
<strong>latest run</strong>
|
<strong>latest indexed run</strong>
|
||||||
<ChevronDown aria-hidden="true" size={16} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<label className="search-box">
|
<label className="search-box">
|
||||||
<Search aria-hidden="true" size={18} />
|
<Search aria-hidden="true" size={18} />
|
||||||
<span className="sr-only">Search URI or hash</span>
|
<span className="sr-only">Exact object URI search is not active yet</span>
|
||||||
<input placeholder="Search URI / hash / ASN / prefix…" />
|
<input disabled placeholder="Exact URI lookup planned in M2" />
|
||||||
</label>
|
</label>
|
||||||
<div className="ready-pill"><span /> Ready</div>
|
<div className="ready-pill"><span /> UI Ready</div>
|
||||||
<button aria-label="Help" className="icon-button" type="button"><CircleHelp size={19} /></button>
|
|
||||||
<button aria-label="Theme" className="icon-button" type="button"><Sun size={19} /></button>
|
|
||||||
<button aria-label="Analytics" className="icon-button" type="button"><BarChart3 size={19} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import { Check, Copy, Download, FileText, GitBranch, Loader2, Search, ShieldCheck } from "lucide-react";
|
import { Check, Copy, Download, FileText, GitBranch, Loader2, Search, ShieldCheck } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { normalizeApiError } from "../../api/client";
|
import { normalizeApiError } from "../../api/client";
|
||||||
|
import { CopyableValue } from "../../components/common/CopyableValue";
|
||||||
import {
|
import {
|
||||||
createObjectSetExport,
|
createObjectSetExport,
|
||||||
createPublicationPointExport,
|
createPublicationPointExport,
|
||||||
@ -14,7 +15,6 @@ import {
|
|||||||
getObjectParsed,
|
getObjectParsed,
|
||||||
getObjectValidation,
|
getObjectValidation,
|
||||||
listManifestFiles,
|
listManifestFiles,
|
||||||
listObjectsForPublicationPoint,
|
|
||||||
listRevokedCertificates,
|
listRevokedCertificates,
|
||||||
type ChainEdgeRecord,
|
type ChainEdgeRecord,
|
||||||
type ObjectInstanceRecord,
|
type ObjectInstanceRecord,
|
||||||
@ -22,7 +22,6 @@ import {
|
|||||||
type ValidationExplainRecord
|
type ValidationExplainRecord
|
||||||
} from "../../api/queryService";
|
} from "../../api/queryService";
|
||||||
|
|
||||||
const SAMPLE_PP_ID = "node_10342";
|
|
||||||
const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const;
|
const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const;
|
||||||
type ObjectTab = (typeof tabs)[number];
|
type ObjectTab = (typeof tabs)[number];
|
||||||
|
|
||||||
@ -33,30 +32,16 @@ interface ObjectDetailPageProps {
|
|||||||
export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) {
|
export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) {
|
||||||
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(initialObjectInstanceId ?? null);
|
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(initialObjectInstanceId ?? null);
|
||||||
const [activeTab, setActiveTab] = useState<ObjectTab>("Validation");
|
const [activeTab, setActiveTab] = useState<ObjectTab>("Validation");
|
||||||
|
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||||
const [searchUri, setSearchUri] = useState("");
|
const [searchUri, setSearchUri] = useState("");
|
||||||
const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun });
|
const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun });
|
||||||
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
||||||
const objectsQuery = useQuery({
|
|
||||||
enabled: Boolean(latestRunQuery.data?.data.runId),
|
|
||||||
queryKey: ["object-detail-sample-objects", runId, SAMPLE_PP_ID],
|
|
||||||
queryFn: () => listObjectsForPublicationPoint(runId, SAMPLE_PP_ID, 50),
|
|
||||||
staleTime: 5 * 60 * 1000
|
|
||||||
});
|
|
||||||
|
|
||||||
const objectRows = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]);
|
|
||||||
const objectOptions = useMemo(
|
|
||||||
() => objectRows.map((object) => ({
|
|
||||||
object,
|
|
||||||
label: `${labelObjectType(object.objectType)} ${shortName(object.uri)}`
|
|
||||||
})),
|
|
||||||
[objectRows]
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedObjectInstanceId && objectRows.length > 0) {
|
if (initialObjectInstanceId && initialObjectInstanceId !== selectedObjectInstanceId) {
|
||||||
const preferred = objectRows.find((item) => item.objectType === "manifest") ?? objectRows[0];
|
setSelectedObjectInstanceId(initialObjectInstanceId);
|
||||||
setSelectedObjectInstanceId(preferred.objectInstanceId);
|
setActiveTab("Validation");
|
||||||
}
|
}
|
||||||
}, [objectRows, selectedObjectInstanceId]);
|
}, [initialObjectInstanceId, selectedObjectInstanceId]);
|
||||||
|
|
||||||
const objectQuery = useQuery({
|
const objectQuery = useQuery({
|
||||||
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
||||||
@ -64,6 +49,7 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
|
|||||||
queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""),
|
queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""),
|
||||||
staleTime: 5 * 60 * 1000
|
staleTime: 5 * 60 * 1000
|
||||||
});
|
});
|
||||||
|
const selectedObject = objectQuery.data?.data ?? null;
|
||||||
const validationQuery = useQuery({
|
const validationQuery = useQuery({
|
||||||
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
|
||||||
queryKey: ["object-detail-validation", runId, selectedObjectInstanceId],
|
queryKey: ["object-detail-validation", runId, selectedObjectInstanceId],
|
||||||
@ -113,44 +99,43 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
|
|||||||
mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : [])
|
mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : [])
|
||||||
});
|
});
|
||||||
const ppExportMutation = useMutation({
|
const ppExportMutation = useMutation({
|
||||||
mutationFn: () => createPublicationPointExport(runId, selectedObject?.ppId ?? SAMPLE_PP_ID)
|
mutationFn: () => {
|
||||||
|
if (!selectedObject?.ppId) {
|
||||||
|
throw new Error("No publication point selected");
|
||||||
|
}
|
||||||
|
return createPublicationPointExport(runId, selectedObject.ppId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedObject = objectQuery.data?.data ?? objectRows.find((item) => item.objectInstanceId === selectedObjectInstanceId) ?? null;
|
useEffect(() => {
|
||||||
|
if (copyState === "idle") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeout = window.setTimeout(() => setCopyState("idle"), 1800);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [copyState]);
|
||||||
|
|
||||||
|
const copySelectedUri = async () => {
|
||||||
|
if (!selectedObject?.uri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(selectedObject.uri);
|
||||||
|
setCopyState("copied");
|
||||||
|
} catch {
|
||||||
|
setCopyState("failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const validation = validationQuery.data?.data ?? null;
|
const validation = validationQuery.data?.data ?? null;
|
||||||
const apiError = latestRunQuery.error ?? objectsQuery.error ?? objectQuery.error ?? validationQuery.error;
|
const apiError = latestRunQuery.error ?? objectQuery.error ?? validationQuery.error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack object-page" aria-labelledby="object-detail-heading">
|
<section className="page-stack object-page" aria-labelledby="object-detail-heading">
|
||||||
{apiError ? <ErrorBanner error={apiError} /> : null}
|
{apiError ? <ErrorBanner error={apiError} /> : null}
|
||||||
<div className="object-layout live-object-layout">
|
<div className="object-detail-only-layout">
|
||||||
<aside className="repository-tree" aria-label="Publication point object list">
|
<aside className="object-detail-card object-detail-card-expanded" aria-label="Live object detail">
|
||||||
<p className="eyebrow">Live object list</p>
|
<div className="object-detail-toolbar">
|
||||||
<h3>sakuya.nat.moe sample PP</h3>
|
|
||||||
<div className="tree-node root">{latestRunQuery.data?.data.runId ?? "Loading run"}</div>
|
|
||||||
{objectsQuery.isFetching ? <LoadingLine label="Loading PP objects..." /> : null}
|
|
||||||
<div className="object-picker-list">
|
|
||||||
{objectOptions.map(({ object, label }) => (
|
|
||||||
<button
|
|
||||||
className={`tree-node nested object-picker ${object.objectInstanceId === selectedObjectInstanceId ? "current" : ""}`}
|
|
||||||
key={object.objectInstanceId}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedObjectInstanceId(object.objectInstanceId);
|
|
||||||
setActiveTab("Validation");
|
|
||||||
explainMutation.reset();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<strong>{object.objectType}</strong>
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div className="object-main">
|
|
||||||
<section className="object-browser" aria-label="Publication point object table">
|
|
||||||
<div className="browser-toolbar">
|
|
||||||
<form
|
<form
|
||||||
className="search-box object-uri-search"
|
className="search-box object-uri-search"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@ -168,53 +153,30 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
|
|||||||
value={searchUri}
|
value={searchUri}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<button className="filter-pill" type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button>
|
<button className="filter-pill" disabled={!selectedObject?.uri} type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button>
|
||||||
<strong>{objectRows.length} objects</strong>
|
<strong>{selectedObject ? `${labelObjectType(selectedObject.objectType)} selected` : "No object selected"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
{!selectedObjectInstanceId ? <div className="empty-state">Search an exact URI or open an object from Repository Browser.</div> : null}
|
||||||
<thead>
|
{selectedObjectInstanceId && objectQuery.isFetching ? <LoadingLine label="Loading selected object..." /> : null}
|
||||||
<tr>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>URI</th>
|
|
||||||
<th>Hash (SHA-256)</th>
|
|
||||||
<th>Source</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{objectRows.map((object) => (
|
|
||||||
<tr
|
|
||||||
className={object.objectInstanceId === selectedObjectInstanceId ? "object-row-active" : ""}
|
|
||||||
key={object.objectInstanceId}
|
|
||||||
onClick={() => setSelectedObjectInstanceId(object.objectInstanceId)}
|
|
||||||
>
|
|
||||||
<td><span className="object-type">{labelObjectType(object.objectType)}</span></td>
|
|
||||||
<td className="uri-cell">{object.uri}</td>
|
|
||||||
<td>{object.sha256.slice(0, 16)}...</td>
|
|
||||||
<td>{object.sourceSection}</td>
|
|
||||||
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside className="object-detail-card" aria-label="Live object detail">
|
|
||||||
<div className="object-hero">
|
<div className="object-hero">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Object detail · live query service</p>
|
<p className="eyebrow">Object detail · live query service</p>
|
||||||
<h2 id="object-detail-heading">{selectedObject ? labelObjectType(selectedObject.objectType) : "Object"} object</h2>
|
<h2 id="object-detail-heading">{selectedObject ? labelObjectType(selectedObject.objectType) : "Object"} object</h2>
|
||||||
<p>{selectedObject?.uri ?? "Select an object from the publication point list."}</p>
|
{selectedObject ? (
|
||||||
|
<p><CopyableValue className="inline-copyable" label="selected object URI" value={selectedObject.uri} /></p>
|
||||||
|
) : (
|
||||||
|
<p>Select an object from the publication point list.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="object-actions">
|
<div className="object-actions">
|
||||||
<StatusPill state={validation?.finalStatus ?? selectedObject?.result ?? "loading"} />
|
<StatusPill state={validation?.finalStatus ?? selectedObject?.result ?? "loading"} />
|
||||||
<button className="ghost-button" type="button">
|
<button className="ghost-button" disabled={!selectedObject?.uri} onClick={copySelectedUri} type="button">
|
||||||
<Copy size={16} /> Copy URI
|
<Copy size={16} /> {copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : "Copy URI"}
|
||||||
</button>
|
</button>
|
||||||
<button className="ghost-button" onClick={() => objectExportMutation.mutate()} type="button">
|
<button className="ghost-button" disabled={!selectedObjectInstanceId} onClick={() => objectExportMutation.mutate()} type="button">
|
||||||
<FileText size={16} /> Export object
|
<FileText size={16} /> Export object
|
||||||
</button>
|
</button>
|
||||||
<button className="primary-button" onClick={() => rawDownloadMutation.mutate()} type="button">
|
<button className="primary-button" disabled={!selectedObjectInstanceId} onClick={() => rawDownloadMutation.mutate()} type="button">
|
||||||
<Download size={16} /> Download raw
|
<Download size={16} /> Download raw
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -232,19 +194,32 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
|
|||||||
searchPending={uriSearchMutation.isPending}
|
searchPending={uriSearchMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<div className="object-actions inline-actions">
|
<div className="object-actions inline-actions">
|
||||||
<button className="ghost-button" onClick={() => ppExportMutation.mutate()} type="button">
|
<button className="ghost-button" disabled={!selectedObject?.ppId} onClick={() => ppExportMutation.mutate()} type="button">
|
||||||
<FileText size={16} /> Export selected PP
|
<FileText size={16} /> Export selected PP
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tabs" role="tablist" aria-label="Object detail tabs">
|
<div className="tabs" role="tablist" aria-label="Object detail tabs" onKeyDown={(event) => {
|
||||||
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const currentIndex = tabs.indexOf(activeTab);
|
||||||
|
const nextIndex = event.key === "ArrowRight"
|
||||||
|
? (currentIndex + 1) % tabs.length
|
||||||
|
: (currentIndex - 1 + tabs.length) % tabs.length;
|
||||||
|
setActiveTab(tabs[nextIndex]);
|
||||||
|
}}>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
|
aria-controls={`object-tabpanel-${tabId(tab)}`}
|
||||||
aria-selected={activeTab === tab}
|
aria-selected={activeTab === tab}
|
||||||
className={activeTab === tab ? "active" : ""}
|
className={activeTab === tab ? "active" : ""}
|
||||||
|
id={`object-tab-${tabId(tab)}`}
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === tab ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@ -252,7 +227,12 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="object-content-grid">
|
<div
|
||||||
|
aria-labelledby={`object-tab-${tabId(activeTab)}`}
|
||||||
|
className="object-content-grid"
|
||||||
|
id={`object-tabpanel-${tabId(activeTab)}`}
|
||||||
|
role="tabpanel"
|
||||||
|
>
|
||||||
{activeTab === "Parsed" ? <ParsedPanel parsed={parsedQuery.data?.data ?? null} isFetching={parsedQuery.isFetching} error={parsedQuery.error} /> : null}
|
{activeTab === "Parsed" ? <ParsedPanel parsed={parsedQuery.data?.data ?? null} isFetching={parsedQuery.isFetching} error={parsedQuery.error} /> : null}
|
||||||
{activeTab === "Validation" ? (
|
{activeTab === "Validation" ? (
|
||||||
<ValidationPanel validation={validation} isFetching={validationQuery.isFetching} explain={explainMutation.data?.data ?? null} explainPending={explainMutation.isPending} onExplain={() => explainMutation.mutate()} explainError={explainMutation.error} />
|
<ValidationPanel validation={validation} isFetching={validationQuery.isFetching} explain={explainMutation.data?.data ?? null} explainPending={explainMutation.isPending} onExplain={() => explainMutation.mutate()} explainError={explainMutation.error} />
|
||||||
@ -263,7 +243,6 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -271,10 +250,10 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr
|
|||||||
function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) {
|
function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="object-meta-grid">
|
<div className="object-meta-grid">
|
||||||
<Meta label="SHA256" value={object.sha256} />
|
<Meta label="SHA256" value={object.sha256} copyable />
|
||||||
<Meta label="Source" value={object.sourceSection} />
|
<Meta label="Source" value={object.sourceSection} />
|
||||||
<Meta label="Repository" value={object.repoId} />
|
<Meta label="Repository" value={object.repoId} copyable />
|
||||||
<Meta label="Publication Point" value={object.ppId} />
|
<Meta label="Publication Point" value={object.ppId} copyable />
|
||||||
<Meta label="Audit Result" value={validation?.auditResult ?? object.result} />
|
<Meta label="Audit Result" value={validation?.auditResult ?? object.result} />
|
||||||
<Meta label="Authoritative" value={validation ? String(validation.authoritative) : "loading"} />
|
<Meta label="Authoritative" value={validation ? String(validation.authoritative) : "loading"} />
|
||||||
</div>
|
</div>
|
||||||
@ -398,7 +377,7 @@ function ChainPanel({ edges, isFetching, error }: { edges: ChainEdgeRecord[]; is
|
|||||||
<div className="chain-node" key={`${edge.relation}-${edge.toUri}`}>
|
<div className="chain-node" key={`${edge.relation}-${edge.toUri}`}>
|
||||||
<strong>{edge.relation}</strong>
|
<strong>{edge.relation}</strong>
|
||||||
<span>{edge.status}</span>
|
<span>{edge.status}</span>
|
||||||
<code>{edge.toUri}</code>
|
<code><CopyableValue label="chain edge URI" value={edge.toUri} /></code>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -498,11 +477,11 @@ function LoadingLine({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Meta({ label, value }: { label: string; value: string }) {
|
function Meta({ copyable, label, value }: { copyable?: boolean; label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="meta-item">
|
<div className="meta-item">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<strong>{value}</strong>
|
<strong>{copyable ? <CopyableValue label={label} value={value} /> : value}</strong>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -518,8 +497,8 @@ function labelObjectType(type: string) {
|
|||||||
return labels[type] ?? type.toUpperCase();
|
return labels[type] ?? type.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortName(uri: string) {
|
function tabId(tab: ObjectTab) {
|
||||||
return uri.split("/").filter(Boolean).at(-1) ?? uri;
|
return tab.toLowerCase().replaceAll(" ", "-");
|
||||||
}
|
}
|
||||||
|
|
||||||
function issuesText(issues: { summary?: string }[] | undefined) {
|
function issuesText(issues: { summary?: string }[] | undefined) {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
type RunRecord
|
type RunRecord
|
||||||
} from "../../api/queryService";
|
} from "../../api/queryService";
|
||||||
import { normalizeApiError } from "../../api/client";
|
import { normalizeApiError } from "../../api/client";
|
||||||
|
import { CopyableValue } from "../../components/common/CopyableValue";
|
||||||
|
|
||||||
const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"];
|
const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"];
|
||||||
const objectTypeLabels: Record<string, string> = {
|
const objectTypeLabels: Record<string, string> = {
|
||||||
@ -58,7 +59,7 @@ export function OverviewPage() {
|
|||||||
const objectRows = objectTypesQuery.data?.data ? objectRowsFromStats(objectTypesQuery.data.data) : objectTypeRows;
|
const objectRows = objectTypesQuery.data?.data ? objectRowsFromStats(objectTypesQuery.data.data) : objectTypeRows;
|
||||||
const validationRows = validationQuery.data?.data ? validationRowsFromStats(validationQuery.data.data) : validationSlices;
|
const validationRows = validationQuery.data?.data ? validationRowsFromStats(validationQuery.data.data) : validationSlices;
|
||||||
const validPercent = percent(validationQuery.data?.data?.ok ?? validationRows[0]?.value ?? 0, sumValues(validationQuery.data?.data) || 100);
|
const validPercent = percent(validationQuery.data?.data?.ok ?? validationRows[0]?.value ?? 0, sumValues(validationQuery.data?.data) || 100);
|
||||||
const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : topRepositories;
|
const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : reposFromFixtures();
|
||||||
const issueRows = reasonsQuery.data?.data ? issuesFromReasons(reasonsQuery.data.data) : validationIssues;
|
const issueRows = reasonsQuery.data?.data ? issuesFromReasons(reasonsQuery.data.data) : validationIssues;
|
||||||
const statusText = liveRun ? statusForRun(liveRun) : "Static fixture";
|
const statusText = liveRun ? statusForRun(liveRun) : "Static fixture";
|
||||||
const statusSubtext = liveRun ? `run ${liveRun.runSeq ?? liveRun.runId} · ${formatDuration(liveRun.wallMs)}` : "query service not connected";
|
const statusSubtext = liveRun ? `run ${liveRun.runSeq ?? liveRun.runId} · ${formatDuration(liveRun.wallMs)}` : "query service not connected";
|
||||||
@ -172,7 +173,7 @@ export function OverviewPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{repoRows.map((repo) => (
|
{repoRows.map((repo) => (
|
||||||
<tr key={repo.host}>
|
<tr key={repo.id}>
|
||||||
<td>{repo.host}</td>
|
<td>{repo.host}</td>
|
||||||
<td>{repo.transport}</td>
|
<td>{repo.transport}</td>
|
||||||
<td>{repo.duration}</td>
|
<td>{repo.duration}</td>
|
||||||
@ -202,7 +203,7 @@ export function OverviewPage() {
|
|||||||
<strong>{issue.type}</strong>
|
<strong>{issue.type}</strong>
|
||||||
</div>
|
</div>
|
||||||
<p>{issue.reason}</p>
|
<p>{issue.reason}</p>
|
||||||
<code>{issue.uri}</code>
|
<code><CopyableValue label="issue URI" value={issue.uri} /></code>
|
||||||
<small>{issue.repo}</small>
|
<small>{issue.repo}</small>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@ -253,6 +254,7 @@ function reposFromApi(repos: RepositoryRecord[]) {
|
|||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((repo) => ({
|
.map((repo) => ({
|
||||||
host: repo.host,
|
host: repo.host,
|
||||||
|
id: repo.repoId,
|
||||||
transport: repo.transport.toUpperCase(),
|
transport: repo.transport.toUpperCase(),
|
||||||
duration: formatDuration(repo.syncDurationMsTotal),
|
duration: formatDuration(repo.syncDurationMsTotal),
|
||||||
objects: formatNumber(repo.objects),
|
objects: formatNumber(repo.objects),
|
||||||
@ -260,6 +262,17 @@ function reposFromApi(repos: RepositoryRecord[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reposFromFixtures() {
|
||||||
|
return topRepositories.map((repo, index) => ({
|
||||||
|
...repo,
|
||||||
|
id: fallbackRepoId(repo, index)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackRepoId(repo: { host: string; transport: string; duration: string; objects: string }, index: number) {
|
||||||
|
return `${repo.host}-${repo.transport}-${repo.duration}-${repo.objects}-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
function issuesFromReasons(reasons: CountsByKey) {
|
function issuesFromReasons(reasons: CountsByKey) {
|
||||||
const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left);
|
const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
|
|||||||
@ -1,51 +1,101 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronRight, Database, FileCode2, GitBranch, Loader2 } from "lucide-react";
|
import { ChevronRight, Database, FileCode2, GitBranch, Loader2, PanelLeftClose, PanelRightClose } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||||
import { useState } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { CopyableValue } from "../../components/common/CopyableValue";
|
||||||
|
import { CursorPager } from "../../components/common/CursorPager";
|
||||||
|
import { advancePage, previousPage, type PageState } from "../../components/common/cursorPaging";
|
||||||
|
import { createEmptyPageState, type RepositoryBrowserState } from "./repositoryBrowserState";
|
||||||
import {
|
import {
|
||||||
getLatestRun,
|
getLatestRun,
|
||||||
listObjectsForPublicationPoint,
|
listObjectsForPublicationPoint,
|
||||||
listPublicationPointsForRepo,
|
listPublicationPointsForRepo,
|
||||||
listRepos,
|
listRepos,
|
||||||
type ObjectInstanceRecord
|
type ObjectInstanceRecord,
|
||||||
|
type PublicationPointRecord,
|
||||||
|
type RepositoryRecord
|
||||||
} from "../../api/queryService";
|
} from "../../api/queryService";
|
||||||
import { normalizeApiError } from "../../api/client";
|
import { normalizeApiError } from "../../api/client";
|
||||||
|
|
||||||
|
const REPO_PAGE_SIZE = 50;
|
||||||
|
const PP_PAGE_SIZE = 50;
|
||||||
|
const OBJECT_PAGE_SIZE = 50;
|
||||||
|
|
||||||
interface RepositoriesPageProps {
|
interface RepositoriesPageProps {
|
||||||
|
browserState: RepositoryBrowserState;
|
||||||
|
onBrowserStateChange: Dispatch<SetStateAction<RepositoryBrowserState>>;
|
||||||
onOpenObject?: (objectInstanceId: string) => void;
|
onOpenObject?: (objectInstanceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) {
|
export function RepositoriesPage({ browserState, onBrowserStateChange, onOpenObject }: RepositoriesPageProps) {
|
||||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
|
const {
|
||||||
const [selectedPpId, setSelectedPpId] = useState<string | null>(null);
|
selectedRepoId,
|
||||||
|
selectedPpId,
|
||||||
|
repoFilter,
|
||||||
|
ppFilter,
|
||||||
|
objectFilter,
|
||||||
|
repoPage,
|
||||||
|
ppPage,
|
||||||
|
objectPage,
|
||||||
|
isRepoPanelCollapsed,
|
||||||
|
isPpPanelCollapsed
|
||||||
|
} = browserState;
|
||||||
|
const setRepoFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, repoFilter: value }));
|
||||||
|
const setPpFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, ppFilter: value }));
|
||||||
|
const setObjectFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, objectFilter: value }));
|
||||||
|
const setRepoPage = (updater: SetStateAction<PageState>) => {
|
||||||
|
onBrowserStateChange((state) => ({
|
||||||
|
...state,
|
||||||
|
repoPage: typeof updater === "function" ? updater(state.repoPage) : updater
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const setPpPage = (updater: SetStateAction<PageState>) => {
|
||||||
|
onBrowserStateChange((state) => ({
|
||||||
|
...state,
|
||||||
|
ppPage: typeof updater === "function" ? updater(state.ppPage) : updater
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const setObjectPage = (updater: SetStateAction<PageState>) => {
|
||||||
|
onBrowserStateChange((state) => ({
|
||||||
|
...state,
|
||||||
|
objectPage: typeof updater === "function" ? updater(state.objectPage) : updater
|
||||||
|
}));
|
||||||
|
};
|
||||||
const latestRunQuery = useQuery({
|
const latestRunQuery = useQuery({
|
||||||
queryKey: ["repositories-latest-run"],
|
queryKey: ["repositories-latest-run"],
|
||||||
queryFn: getLatestRun
|
queryFn: getLatestRun
|
||||||
});
|
});
|
||||||
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
const runId = latestRunQuery.data?.data.runId ?? "latest";
|
||||||
const reposQuery = useQuery({
|
const reposQuery = useQuery({
|
||||||
queryKey: ["repositories", runId],
|
queryKey: ["repositories", runId, repoPage.cursor],
|
||||||
queryFn: () => listRepos(runId, 50),
|
queryFn: () => listRepos(runId, REPO_PAGE_SIZE, repoPage.cursor),
|
||||||
enabled: Boolean(latestRunQuery.data?.data.runId)
|
enabled: Boolean(latestRunQuery.data?.data.runId)
|
||||||
});
|
});
|
||||||
|
|
||||||
const repos = reposQuery.data?.data ?? [];
|
const repos = useMemo(() => reposQuery.data?.data ?? [], [reposQuery.data?.data]);
|
||||||
|
const filteredRepos = useMemo(() => filterRepositories(repos, repoFilter), [repoFilter, repos]);
|
||||||
const selectedRepo = selectedRepoId ? repos.find((repo) => repo.repoId === selectedRepoId) ?? null : null;
|
const selectedRepo = selectedRepoId ? repos.find((repo) => repo.repoId === selectedRepoId) ?? null : null;
|
||||||
const ppsQuery = useQuery({
|
const ppsQuery = useQuery({
|
||||||
queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? ""],
|
queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? "", ppPage.cursor],
|
||||||
queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", 50),
|
queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", PP_PAGE_SIZE, ppPage.cursor),
|
||||||
enabled: Boolean(selectedRepo?.repoId)
|
enabled: Boolean(selectedRepo?.repoId)
|
||||||
});
|
});
|
||||||
|
|
||||||
const publicationPoints = ppsQuery.data?.data ?? [];
|
const publicationPoints = useMemo(() => ppsQuery.data?.data ?? [], [ppsQuery.data?.data]);
|
||||||
|
const filteredPublicationPoints = useMemo(() => filterPublicationPoints(publicationPoints, ppFilter), [ppFilter, publicationPoints]);
|
||||||
const selectedPp = selectedPpId
|
const selectedPp = selectedPpId
|
||||||
? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null
|
? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null
|
||||||
: null;
|
: null;
|
||||||
const objectsQuery = useQuery({
|
const objectsQuery = useQuery({
|
||||||
queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? ""],
|
queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? "", objectPage.cursor],
|
||||||
queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", 50),
|
queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", OBJECT_PAGE_SIZE, objectPage.cursor),
|
||||||
enabled: Boolean(selectedPp?.ppId)
|
enabled: Boolean(selectedPp?.ppId)
|
||||||
});
|
});
|
||||||
|
const objects = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]);
|
||||||
|
const filteredObjects = useMemo(() => filterObjects(objects, objectFilter), [objectFilter, objects]);
|
||||||
|
const repoTotal = reposQuery.data?.page?.nextCursor ? undefined : repoPage.previous.length * REPO_PAGE_SIZE + repos.length;
|
||||||
|
const ppTotal = selectedRepo?.publicationPoints;
|
||||||
|
const objectTotal = selectedPp?.objects;
|
||||||
|
|
||||||
const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error;
|
const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error;
|
||||||
|
|
||||||
@ -73,94 +123,236 @@ export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="repo-browser-grid">
|
<div className={`repo-browser-grid ${isRepoPanelCollapsed ? "repo-panel-collapsed" : ""} ${isPpPanelCollapsed ? "pp-panel-collapsed" : ""}`}>
|
||||||
<section className="panel list-panel" aria-label="Repositories">
|
<section className="panel list-panel collapsible-list-panel repo-list-panel" aria-label="Repositories">
|
||||||
<PanelTitle icon={<Database size={18} />} label="Repositories" meta={reposQuery.isFetching ? "loading" : `${repos.length} shown`} />
|
<PanelTitle
|
||||||
|
icon={<Database size={18} />}
|
||||||
|
isCollapsed={isRepoPanelCollapsed}
|
||||||
|
label="Repositories"
|
||||||
|
meta={reposQuery.isFetching ? "loading" : rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
|
||||||
|
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isRepoPanelCollapsed: !state.isRepoPanelCollapsed }))}
|
||||||
|
toggleLabel={isRepoPanelCollapsed ? "Expand repositories column" : "Collapse repositories column"}
|
||||||
|
/>
|
||||||
|
{isRepoPanelCollapsed ? (
|
||||||
|
<CollapsedPanelSummary icon={<Database size={20} />} label="Repositories" value={repoTotal ?? repos.length} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CurrentPageFilter
|
||||||
|
label="Filter repositories on current page"
|
||||||
|
onChange={setRepoFilter}
|
||||||
|
placeholder="Filter host or URI..."
|
||||||
|
value={repoFilter}
|
||||||
|
/>
|
||||||
<div className="stack-list">
|
<div className="stack-list">
|
||||||
{repos.map((repo) => (
|
{filteredRepos.map((repo) => (
|
||||||
|
<article className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`} key={repo.repoId}>
|
||||||
<button
|
<button
|
||||||
className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`}
|
className="stack-row-select"
|
||||||
key={repo.repoId}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedRepoId(repo.repoId);
|
onBrowserStateChange((state) => ({
|
||||||
setSelectedPpId(null);
|
...state,
|
||||||
|
selectedRepoId: repo.repoId,
|
||||||
|
selectedPpId: null,
|
||||||
|
ppFilter: "",
|
||||||
|
objectFilter: "",
|
||||||
|
ppPage: createEmptyPageState(),
|
||||||
|
objectPage: createEmptyPageState()
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong>{repo.host}</strong>
|
<strong>{repo.host}</strong>
|
||||||
<small>{repo.uri}</small>
|
<small>{repo.transport.toUpperCase()} · {repo.publicationPoints.toLocaleString()} publication points</small>
|
||||||
</span>
|
</span>
|
||||||
<em>{repo.objects.toLocaleString()}</em>
|
<em>{repo.objects.toLocaleString()}</em>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<CopyableValue className="stack-row-copy" label="repository URI" value={repo.uri} />
|
||||||
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<CursorPager
|
||||||
|
isFetching={reposQuery.isFetching}
|
||||||
|
pageNumber={repoPage.previous.length + 1}
|
||||||
|
nextCursor={reposQuery.data?.page?.nextCursor ?? null}
|
||||||
|
onNext={() => setRepoPage((page) => advancePage(page, reposQuery.data?.page?.nextCursor ?? null))}
|
||||||
|
onPrevious={() => setRepoPage(previousPage)}
|
||||||
|
previousCount={repoPage.previous.length}
|
||||||
|
rangeLabel={rangeLabel(repoPage.previous.length + 1, REPO_PAGE_SIZE, repos.length, repoTotal, repoFilter, filteredRepos.length)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel list-panel" aria-label="Publication points">
|
<section className="panel list-panel collapsible-list-panel pp-list-panel" aria-label="Publication points">
|
||||||
<PanelTitle
|
<PanelTitle
|
||||||
icon={<GitBranch size={18} />}
|
icon={<GitBranch size={18} />}
|
||||||
|
isCollapsed={isPpPanelCollapsed}
|
||||||
label="Publication Points"
|
label="Publication Points"
|
||||||
meta={ppsQuery.isFetching ? "loading" : `${publicationPoints.length} shown`}
|
meta={ppsQuery.isFetching ? "loading" : selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : "select repo"}
|
||||||
|
onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isPpPanelCollapsed: !state.isPpPanelCollapsed }))}
|
||||||
|
toggleLabel={isPpPanelCollapsed ? "Expand publication points column" : "Collapse publication points column"}
|
||||||
/>
|
/>
|
||||||
|
{isPpPanelCollapsed ? (
|
||||||
|
<CollapsedPanelSummary icon={<GitBranch size={20} />} label="Publication Points" value={ppTotal ?? publicationPoints.length} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{!selectedRepo ? (
|
{!selectedRepo ? (
|
||||||
<div className="empty-state">Select a repository to load its publication points.</div>
|
<div className="empty-state">Select a repository to load its publication points.</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<CurrentPageFilter
|
||||||
|
disabled={!selectedRepo}
|
||||||
|
label="Filter publication points on current page"
|
||||||
|
onChange={setPpFilter}
|
||||||
|
placeholder="Filter manifest, rsync, source..."
|
||||||
|
value={ppFilter}
|
||||||
|
/>
|
||||||
{ppsQuery.isFetching ? <LoadingLine /> : null}
|
{ppsQuery.isFetching ? <LoadingLine /> : null}
|
||||||
<div className="stack-list">
|
<div className="stack-list">
|
||||||
{publicationPoints.map((pp) => (
|
{filteredPublicationPoints.map((pp) => (
|
||||||
|
<article className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`} key={pp.ppId}>
|
||||||
<button
|
<button
|
||||||
className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`}
|
className="stack-row-select"
|
||||||
key={pp.ppId}
|
onClick={() => {
|
||||||
onClick={() => setSelectedPpId(pp.ppId)}
|
onBrowserStateChange((state) => ({
|
||||||
|
...state,
|
||||||
|
selectedPpId: pp.ppId,
|
||||||
|
objectFilter: "",
|
||||||
|
objectPage: createEmptyPageState()
|
||||||
|
}));
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong>
|
<strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong>
|
||||||
<small>{pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? "unknown"}</small>
|
<small>{pp.repoSyncSource ?? pp.source ?? "unknown"} · {pp.ppId}</small>
|
||||||
</span>
|
</span>
|
||||||
<em>{pp.objects.toLocaleString()}</em>
|
<em>{pp.objects.toLocaleString()}</em>
|
||||||
<StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} />
|
<StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} />
|
||||||
</button>
|
</button>
|
||||||
|
<CopyableValue className="stack-row-copy" label="publication point URI" value={pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? pp.ppId} />
|
||||||
|
{pp.manifestRsyncUri ? <CopyableValue className="stack-row-copy secondary" label="manifest URI" value={pp.manifestRsyncUri} /> : null}
|
||||||
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<CursorPager
|
||||||
|
isFetching={ppsQuery.isFetching}
|
||||||
|
nextCursor={ppsQuery.data?.page?.nextCursor ?? null}
|
||||||
|
onNext={() => setPpPage((page) => advancePage(page, ppsQuery.data?.page?.nextCursor ?? null))}
|
||||||
|
onPrevious={() => setPpPage(previousPage)}
|
||||||
|
pageNumber={ppPage.previous.length + 1}
|
||||||
|
previousCount={ppPage.previous.length}
|
||||||
|
rangeLabel={selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel table-panel objects-live-panel" aria-label="Objects for publication point">
|
<section className="panel table-panel objects-live-panel" aria-label="Objects for publication point">
|
||||||
<PanelTitle
|
<PanelTitle
|
||||||
icon={<FileCode2 size={18} />}
|
icon={<FileCode2 size={18} />}
|
||||||
label="Objects"
|
label="Objects"
|
||||||
meta={objectsQuery.isFetching ? "loading" : `${objectsQuery.data?.data.length ?? 0} of selected PP`}
|
meta={objectsQuery.isFetching ? "loading" : selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : "select PP"}
|
||||||
/>
|
/>
|
||||||
{!selectedPp ? (
|
{!selectedPp ? (
|
||||||
<div className="empty-state">Select a publication point to load its objects.</div>
|
<div className="empty-state">Select a publication point to load its objects.</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<CurrentPageFilter
|
||||||
|
disabled={!selectedPp}
|
||||||
|
label="Filter objects on current page"
|
||||||
|
onChange={setObjectFilter}
|
||||||
|
placeholder="Filter type, URI, hash, status..."
|
||||||
|
value={objectFilter}
|
||||||
|
/>
|
||||||
{objectsQuery.isFetching ? <LoadingLine /> : null}
|
{objectsQuery.isFetching ? <LoadingLine /> : null}
|
||||||
{selectedPp && !objectsQuery.isFetching ? (
|
{selectedPp && !objectsQuery.isFetching ? (
|
||||||
<ObjectTable objects={objectsQuery.data?.data ?? []} onOpenObject={onOpenObject} />
|
<ObjectTable objects={filteredObjects} onOpenObject={onOpenObject} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="pagination-note">
|
<CursorPager
|
||||||
Default query uses <code>limit=50</code>. Next cursor: <code>{objectsQuery.data?.page?.nextCursor ?? "none"}</code>
|
isFetching={objectsQuery.isFetching}
|
||||||
</div>
|
nextCursor={objectsQuery.data?.page?.nextCursor ?? null}
|
||||||
|
onNext={() => setObjectPage((page) => advancePage(page, objectsQuery.data?.page?.nextCursor ?? null))}
|
||||||
|
onPrevious={() => setObjectPage(previousPage)}
|
||||||
|
pageNumber={objectPage.previous.length + 1}
|
||||||
|
previousCount={objectPage.previous.length}
|
||||||
|
rangeLabel={selectedPp ? rangeLabel(objectPage.previous.length + 1, OBJECT_PAGE_SIZE, objects.length, objectTotal, objectFilter, filteredObjects.length) : undefined}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelTitle({ icon, label, meta }: { icon: ReactNode; label: string; meta: string }) {
|
function PanelTitle({
|
||||||
|
icon,
|
||||||
|
isCollapsed,
|
||||||
|
label,
|
||||||
|
meta,
|
||||||
|
onToggleCollapse,
|
||||||
|
toggleLabel
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
label: string;
|
||||||
|
meta: string;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
toggleLabel?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="panel-heading compact-heading">
|
<div className="panel-heading compact-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{label}</p>
|
<p className="eyebrow">{label}</p>
|
||||||
<h3>{label}</h3>
|
<h3>{label}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="panel-heading-actions">
|
||||||
<span className="panel-meta">
|
<span className="panel-meta">
|
||||||
{icon}
|
{icon}
|
||||||
{meta}
|
{meta}
|
||||||
</span>
|
</span>
|
||||||
|
{onToggleCollapse ? (
|
||||||
|
<button aria-expanded={!isCollapsed} aria-label={toggleLabel} className="panel-collapse-button" onClick={onToggleCollapse} title={toggleLabel} type="button">
|
||||||
|
{isCollapsed ? <PanelRightClose aria-hidden="true" size={16} /> : <PanelLeftClose aria-hidden="true" size={16} />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedPanelSummary({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="collapsed-panel-summary">
|
||||||
|
{icon}
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<span>{value.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrentPageFilter({
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
label: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="current-page-filter">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,8 +376,8 @@ function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[
|
|||||||
{objects.map((object) => (
|
{objects.map((object) => (
|
||||||
<tr key={object.objectInstanceId}>
|
<tr key={object.objectInstanceId}>
|
||||||
<td><span className="object-type">{object.objectType}</span></td>
|
<td><span className="object-type">{object.objectType}</span></td>
|
||||||
<td className="uri-cell">{object.uri}</td>
|
<td className="uri-cell"><CopyableValue label="object URI" value={object.uri} /></td>
|
||||||
<td>{object.sha256.slice(0, 12)}...</td>
|
<td><CopyableValue label="object SHA256" value={object.sha256} displayValue={shortHash(object.sha256, 12)} /></td>
|
||||||
<td>{object.sourceSection}</td>
|
<td>{object.sourceSection}</td>
|
||||||
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
|
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
|
||||||
<td>
|
<td>
|
||||||
@ -222,3 +414,54 @@ function LoadingLine() {
|
|||||||
function shortName(uri: string) {
|
function shortName(uri: string) {
|
||||||
return uri.split("/").filter(Boolean).at(-1) ?? uri;
|
return uri.split("/").filter(Boolean).at(-1) ?? uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortHash(value: string, prefixLength: number) {
|
||||||
|
return `${value.slice(0, prefixLength)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeLabel(pageNumber: number, pageSize: number, pageRowCount: number, total: number | undefined, filter: string, visibleRowCount = pageRowCount) {
|
||||||
|
const start = pageRowCount === 0 ? 0 : (pageNumber - 1) * pageSize + 1;
|
||||||
|
const end = pageRowCount === 0 ? 0 : start + pageRowCount - 1;
|
||||||
|
const totalText = total === undefined ? "unknown" : total.toLocaleString();
|
||||||
|
const filterText = filter.trim() ? ` · ${visibleRowCount.toLocaleString()}/${pageRowCount.toLocaleString()} matched on page` : "";
|
||||||
|
return `${start.toLocaleString()}-${end.toLocaleString()}/${totalText} shown${filterText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesNeedle(values: Array<string | number | null | undefined>, needle: string) {
|
||||||
|
const normalized = needle.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return values.some((value) => String(value ?? "").toLowerCase().includes(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRepositories(repos: RepositoryRecord[], filter: string) {
|
||||||
|
return repos.filter((repo) => matchesNeedle([repo.host, repo.uri, repo.transport, repo.objects, repo.rejectedObjects], filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPublicationPoints(publicationPoints: PublicationPointRecord[], filter: string) {
|
||||||
|
return publicationPoints.filter((pp) => matchesNeedle([
|
||||||
|
pp.ppId,
|
||||||
|
pp.manifestRsyncUri,
|
||||||
|
pp.publicationPointRsyncUri,
|
||||||
|
pp.rsyncBaseUri,
|
||||||
|
pp.rrdpNotificationUri,
|
||||||
|
pp.source,
|
||||||
|
pp.repoSyncSource,
|
||||||
|
pp.repoTerminalState,
|
||||||
|
pp.objects,
|
||||||
|
pp.rejectedObjects
|
||||||
|
], filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterObjects(objects: ObjectInstanceRecord[], filter: string) {
|
||||||
|
return objects.filter((object) => matchesNeedle([
|
||||||
|
object.objectType,
|
||||||
|
object.uri,
|
||||||
|
object.sha256,
|
||||||
|
object.sourceSection,
|
||||||
|
object.result,
|
||||||
|
object.rejectReason,
|
||||||
|
object.rejected ? "rejected" : "accepted"
|
||||||
|
], filter));
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
background: #f7faff;
|
background: #f7faff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,12 +39,20 @@ input:focus-visible {
|
|||||||
grid-template-columns: 230px minmax(0, 1fr);
|
grid-template-columns: 230px minmax(0, 1fr);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(180deg, #ffffff 0%, #f4f8fe 100%);
|
background: linear-gradient(180deg, #ffffff 0%, #f4f8fe 100%);
|
||||||
|
transition: grid-template-columns 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell.sidebar-collapsed {
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
border-right: 1px solid #dbe4f0;
|
border-right: 1px solid #dbe4f0;
|
||||||
background: rgba(255, 255, 255, 0.88);
|
background: rgba(255, 255, 255, 0.88);
|
||||||
color: #10213f;
|
color: #10213f;
|
||||||
@ -58,7 +67,13 @@ input:focus-visible {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
min-width: 0;
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
|
flex: 0 0 36px;
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@ -85,6 +100,33 @@ input:focus-visible {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid #d2deee;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #24466f;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 9px 12px;
|
||||||
|
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle:hover,
|
||||||
|
.sidebar-toggle:focus-visible {
|
||||||
|
border-color: #b7d2fb;
|
||||||
|
background: #eaf3ff;
|
||||||
|
color: #0759d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: #60718e;
|
color: #60718e;
|
||||||
@ -97,6 +139,7 @@ input:focus-visible {
|
|||||||
.nav-list {
|
.nav-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@ -110,6 +153,7 @@ input:focus-visible {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: #122544;
|
color: #122544;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
transition: background 160ms ease, color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active,
|
.nav-item.active,
|
||||||
@ -119,6 +163,7 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item svg {
|
.nav-item svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
color: #24466f;
|
color: #24466f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,12 +172,51 @@ input:focus-visible {
|
|||||||
color: #0759d7;
|
color: #0759d7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar {
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand {
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand-copy,
|
||||||
|
.sidebar-collapsed .nav-item span,
|
||||||
|
.sidebar-collapsed .sidebar-toggle span {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar-toggle,
|
||||||
|
.sidebar-collapsed .nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar-toggle {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
margin-top: auto;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .nav-item {
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.main-panel {
|
.main-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -165,6 +249,7 @@ h3 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
|
max-width: 100%;
|
||||||
min-width: 206px;
|
min-width: 206px;
|
||||||
border: 1px solid #d2deee;
|
border: 1px solid #d2deee;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
@ -185,6 +270,7 @@ h3 {
|
|||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@ -194,6 +280,7 @@ h3 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
width: min(740px, 100%);
|
width: min(740px, 100%);
|
||||||
border: 1px solid #d2deee;
|
border: 1px solid #d2deee;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
@ -265,6 +352,7 @@ h3 {
|
|||||||
|
|
||||||
.hero-dashboard {
|
.hero-dashboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@ -287,6 +375,7 @@ h3 {
|
|||||||
|
|
||||||
.hero-status {
|
.hero-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
max-width: 100%;
|
||||||
min-width: 230px;
|
min-width: 230px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -391,8 +480,9 @@ h3 {
|
|||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(360px, 0.96fr) minmax(420px, 1.04fr) minmax(360px, 0.92fr);
|
grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr) minmax(0, 0.92fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel,
|
.panel,
|
||||||
@ -402,6 +492,7 @@ h3 {
|
|||||||
.tabs,
|
.tabs,
|
||||||
.object-browser,
|
.object-browser,
|
||||||
.object-detail-card {
|
.object-detail-card {
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid #dbe4f0;
|
border: 1px solid #dbe4f0;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: white;
|
background: white;
|
||||||
@ -409,10 +500,15 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
overflow-x: visible;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-panel {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.wide-panel {
|
.wide-panel {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
@ -438,16 +534,101 @@ h3 {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-heading-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-collapse-button {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #d2deee;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: #24466f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-collapse-button:hover,
|
||||||
|
.panel-collapse-button:focus-visible {
|
||||||
|
border-color: #a9c8f6;
|
||||||
|
background: #eaf3ff;
|
||||||
|
color: #0759d7;
|
||||||
|
}
|
||||||
|
|
||||||
.repo-browser-grid {
|
.repo-browser-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(300px, 0.85fr) minmax(340px, 1fr) minmax(520px, 1.35fr);
|
grid-template-columns: minmax(300px, 0.85fr) minmax(340px, 1fr) minmax(520px, 1.35fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-browser-grid.repo-panel-collapsed {
|
||||||
|
grid-template-columns: 76px minmax(340px, 1fr) minmax(520px, 1.35fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-browser-grid.pp-panel-collapsed {
|
||||||
|
grid-template-columns: minmax(300px, 0.85fr) 76px minmax(520px, 1.35fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-browser-grid.repo-panel-collapsed.pp-panel-collapsed {
|
||||||
|
grid-template-columns: 76px 76px minmax(520px, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-panel {
|
.list-panel {
|
||||||
min-height: 640px;
|
min-height: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapsible-list-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-browser-grid.repo-panel-collapsed .repo-list-panel,
|
||||||
|
.repo-browser-grid.pp-panel-collapsed .pp-list-panel {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-heading,
|
||||||
|
.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-heading {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-heading > div:first-child,
|
||||||
|
.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-heading > div:first-child,
|
||||||
|
.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-meta,
|
||||||
|
.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-panel-summary {
|
||||||
|
display: grid;
|
||||||
|
min-height: 520px;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #24466f;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-panel-summary strong {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
color: #07142d;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-panel-summary span {
|
||||||
|
color: #0759d7;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
.stack-list {
|
.stack-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -458,9 +639,8 @@ h3 {
|
|||||||
|
|
||||||
.stack-row {
|
.stack-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #e3ebf5;
|
border: 1px solid #e3ebf5;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -470,27 +650,40 @@ h3 {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stack-row-select {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.stack-row.active,
|
.stack-row.active,
|
||||||
.stack-row:hover {
|
.stack-row:hover {
|
||||||
border-color: #b7d2fb;
|
border-color: #b7d2fb;
|
||||||
background: #eaf3ff;
|
background: #eaf3ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-row strong,
|
.stack-row-select strong,
|
||||||
.stack-row small {
|
.stack-row-select small {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-row small {
|
.stack-row-select small {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #60718e;
|
color: #60718e;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-row em {
|
.stack-row-select em {
|
||||||
color: #0759d7;
|
color: #0759d7;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@ -498,6 +691,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.objects-live-panel {
|
.objects-live-panel {
|
||||||
|
overflow-x: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -518,6 +712,163 @@ h3 {
|
|||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copyable-value {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable-value-text {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: min(100%, 360px);
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: inherit;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-copyable {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-copyable .copyable-value-text {
|
||||||
|
max-width: min(100%, 520px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-row-copy {
|
||||||
|
width: 100%;
|
||||||
|
color: #60718e;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-row-copy.secondary {
|
||||||
|
color: #24466f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-row-copy .copyable-value-text {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-icon-button {
|
||||||
|
display: inline-grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #d2deee;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: white;
|
||||||
|
color: #0b5bd3;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-icon-button:hover,
|
||||||
|
.copy-icon-button:focus-visible {
|
||||||
|
border-color: #a9c8f6;
|
||||||
|
background: #eaf3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-state-text {
|
||||||
|
color: #047857;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: min(760px, calc(100vw - 32px));
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
border: 1px solid #b7d2fb;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #07142d;
|
||||||
|
box-shadow: 0 16px 42px rgba(7, 20, 45, 0.22);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: 9px 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-page-filter {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-page-filter span {
|
||||||
|
color: #60718e;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-page-filter input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #d2deee;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
color: #0f172a;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-page-filter input:disabled {
|
||||||
|
background: #f7faff;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pager {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #60718e;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pager span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 160px;
|
||||||
|
color: #122544;
|
||||||
|
font-weight: 850;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pager small {
|
||||||
|
color: #60718e;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pager button {
|
||||||
|
border: 1px solid #d2deee;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: white;
|
||||||
|
color: #0b5bd3;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pager button:disabled,
|
||||||
|
.ghost-button:disabled,
|
||||||
|
.primary-button:disabled,
|
||||||
|
.table-link-button:disabled,
|
||||||
|
.filter-pill:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination-note,
|
.pagination-note,
|
||||||
.empty-state,
|
.empty-state,
|
||||||
.loading-line {
|
.loading-line {
|
||||||
@ -639,6 +990,7 @@ h3 {
|
|||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 620px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -693,6 +1045,8 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.issue-card {
|
.issue-card {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
border-bottom: 1px solid #e3ebf5;
|
border-bottom: 1px solid #e3ebf5;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
}
|
}
|
||||||
@ -703,17 +1057,23 @@ td {
|
|||||||
|
|
||||||
.issue-card div {
|
.issue-card div {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issue-card p {
|
.issue-card p {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
margin: 9px 0;
|
margin: 9px 0;
|
||||||
color: #122544;
|
color: #122544;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issue-card code {
|
.issue-card code {
|
||||||
display: block;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #53657f;
|
color: #53657f;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -723,6 +1083,8 @@ td {
|
|||||||
|
|
||||||
.issue-card small {
|
.issue-card small {
|
||||||
display: block;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
color: #60718e;
|
color: #60718e;
|
||||||
}
|
}
|
||||||
@ -774,6 +1136,40 @@ td {
|
|||||||
grid-template-columns: 248px minmax(350px, 1fr) minmax(330px, 0.9fr);
|
grid-template-columns: 248px minmax(350px, 1fr) minmax(330px, 0.9fr);
|
||||||
min-height: calc(100vh - 76px);
|
min-height: calc(100vh - 76px);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-only-layout {
|
||||||
|
min-height: calc(100vh - 76px);
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-card-expanded {
|
||||||
|
width: min(100%, 1120px);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #dbe4f0;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-toolbar .search-box {
|
||||||
|
flex: 1 1 420px;
|
||||||
|
max-width: 580px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-toolbar strong {
|
||||||
|
color: #24466f;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repository-tree {
|
.repository-tree {
|
||||||
@ -818,17 +1214,27 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.object-picker {
|
.object-picker {
|
||||||
display: block;
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
width: calc(100% - 18px);
|
width: calc(100% - 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-picker-select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.object-picker strong,
|
.object-picker-select strong,
|
||||||
.object-picker span {
|
.object-picker-select span {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.object-picker span {
|
.object-picker-select span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #60718e;
|
color: #60718e;
|
||||||
@ -842,7 +1248,8 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.object-browser {
|
.object-browser {
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@ -851,6 +1258,7 @@ td {
|
|||||||
|
|
||||||
.browser-toolbar {
|
.browser-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -859,6 +1267,7 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.browser-toolbar .search-box {
|
.browser-toolbar .search-box {
|
||||||
|
flex: 1 1 220px;
|
||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
}
|
}
|
||||||
@ -885,14 +1294,26 @@ td {
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.object-row-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.object-detail-card {
|
.object-detail-card {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
margin: 14px;
|
margin: 14px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.object-detail-card.object-detail-card-expanded {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.object-hero {
|
.object-hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@ -902,6 +1323,11 @@ td {
|
|||||||
padding: 0 0 14px;
|
padding: 0 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.object-hero > div:first-child {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.object-hero p {
|
.object-hero p {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
color: #53657f;
|
color: #53657f;
|
||||||
@ -910,9 +1336,12 @@ td {
|
|||||||
|
|
||||||
.object-actions {
|
.object-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 1 auto;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.object-meta-grid {
|
.object-meta-grid {
|
||||||
@ -948,6 +1377,7 @@ td {
|
|||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #dbe4f0;
|
border-bottom: 1px solid #dbe4f0;
|
||||||
@ -1066,6 +1496,11 @@ td {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.object-browser table,
|
||||||
|
.objects-live-panel table {
|
||||||
|
min-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
.explain-box {
|
.explain-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -1117,13 +1552,27 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-browser-grid {
|
.repo-browser-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repo-browser-grid.repo-panel-collapsed,
|
||||||
|
.repo-browser-grid.pp-panel-collapsed,
|
||||||
|
.repo-browser-grid.repo-panel-collapsed.pp-panel-collapsed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-panel-summary {
|
||||||
|
min-height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-panel-summary strong {
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
|
||||||
.issue-panel {
|
.issue-panel {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
@ -1132,7 +1581,7 @@ td {
|
|||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.dashboard-grid,
|
.dashboard-grid,
|
||||||
.object-layout {
|
.object-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wide-panel,
|
.wide-panel,
|
||||||
@ -1151,9 +1600,47 @@ td {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell.sidebar-collapsed {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: static;
|
position: static;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar {
|
||||||
|
padding: 22px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .brand-copy,
|
||||||
|
.sidebar-collapsed .nav-item span,
|
||||||
|
.sidebar-collapsed .sidebar-toggle span {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
clip: auto;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar-toggle,
|
||||||
|
.sidebar-collapsed .nav-item {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-collapsed .sidebar-toggle {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
@ -1163,6 +1650,55 @@ td {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-stack {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-selector,
|
||||||
|
.search-box,
|
||||||
|
.ready-pill,
|
||||||
|
.hero-status {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-pill {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-layout {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-only-layout {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-toolbar .search-box,
|
||||||
|
.object-detail-toolbar .filter-pill {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-card {
|
||||||
|
margin: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-detail-card-expanded {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-grid,
|
.metric-grid,
|
||||||
.object-meta-grid,
|
.object-meta-grid,
|
||||||
.chain-flow {
|
.chain-flow {
|
||||||
|
|||||||
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";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
|
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||||
|
|
||||||
|
async function openFirstRepositoryObject(page: import("@playwright/test").Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||||
|
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||||
|
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||||
|
await reposSection.locator(".stack-row-select").first().click();
|
||||||
|
await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click();
|
||||||
|
const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first();
|
||||||
|
await expect(row).toBeVisible({ timeout: 70_000 });
|
||||||
|
const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim();
|
||||||
|
await row.getByRole("button", { name: "Open" }).click();
|
||||||
|
await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 });
|
||||||
|
return expectedUri;
|
||||||
|
}
|
||||||
|
|
||||||
test("renders live object detail and lazy tab requests", async ({ page }) => {
|
test("renders live object detail and lazy tab requests", async ({ page }) => {
|
||||||
const apiRequests: string[] = [];
|
const apiRequests: string[] = [];
|
||||||
@ -18,33 +34,32 @@ test("renders live object detail and lazy tab requests", async ({ page }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
await openFirstRepositoryObject(page);
|
||||||
await page.getByRole("button", { name: /Objects/i }).click();
|
await expect(page.getByText("Object detail · live query service")).toBeVisible();
|
||||||
|
await expect(page.getByText("File and chain checks")).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 });
|
|
||||||
await expect(page.getByText("5A179648B3EF2369DCE7BDB58140FF7DC7060ABF.mft").first()).toBeVisible();
|
|
||||||
await expect(page.getByText("Final status: valid")).toBeVisible();
|
|
||||||
const objectDetail = page.getByRole("complementary", { name: "Live object detail" });
|
const objectDetail = page.getByRole("complementary", { name: "Live object detail" });
|
||||||
await expect(objectDetail.getByText("Authoritative", { exact: true })).toBeVisible();
|
await expect(objectDetail.getByText("Authoritative", { exact: true })).toBeVisible();
|
||||||
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/parsed"))).toBe(false);
|
await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible();
|
||||||
|
expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(false);
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Parsed" }).click();
|
await page.getByRole("tab", { name: "Parsed" }).click();
|
||||||
await expect(page.getByText("Projection unavailable")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/Projection JSON|Projection unavailable/)).toBeVisible({ timeout: 30_000 });
|
||||||
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/parsed"))).toBe(true);
|
expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(true);
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Chain" }).click();
|
await page.getByRole("tab", { name: "Chain" }).click();
|
||||||
await expect(page.getByText("No chain edges recorded for this object.")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "object-tab-chain");
|
||||||
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/chain"))).toBe(true);
|
expect(apiRequests.some((request) => request.includes("/chain"))).toBe(true);
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "Validation" }).click();
|
await page.getByRole("tab", { name: "Validation" }).click();
|
||||||
await page.getByRole("button", { name: "Explain validation" }).click();
|
await page.getByRole("button", { name: "Explain validation" }).click();
|
||||||
await expect(page.getByText("audit_projection")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/audit_projection|cached audit projection|Explain failed/)).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(objectDetail.getByText("false").last()).toBeVisible();
|
|
||||||
expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true);
|
expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true);
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-object-detail-live.png`, fullPage: true });
|
||||||
path: "../../../../specs/develop/20260617/m5_playwright/rpki-explorer-object-detail-live.png",
|
|
||||||
fullPage: true
|
|
||||||
});
|
|
||||||
expect(consoleErrors).toEqual([]);
|
expect(consoleErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||||
|
|
||||||
test("renders live overview data from query service without global object list", async ({ page }) => {
|
test("renders live overview data from query service without global object list", async ({ page }) => {
|
||||||
const apiRequests: string[] = [];
|
const apiRequests: string[] = [];
|
||||||
const consoleErrors: string[] = [];
|
const consoleErrors: string[] = [];
|
||||||
|
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
const path = new URL(request.url()).pathname;
|
const url = new URL(request.url());
|
||||||
if (path.startsWith("/api/v1")) {
|
if (url.pathname.startsWith("/api/v1")) {
|
||||||
apiRequests.push(path);
|
apiRequests.push(`${url.pathname}${url.search}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
page.on("console", (message) => {
|
page.on("console", (message) => {
|
||||||
@ -18,22 +20,18 @@ test("renders live overview data from query service without global object list",
|
|||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByText("run 7144")).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
|
||||||
await expect(page.getByText("963,779")).toBeVisible();
|
await expect(page.getByText(/run_\d+/).first()).toBeVisible();
|
||||||
await expect(page.getByText("534,760")).toBeVisible();
|
|
||||||
await expect(page.getByText("Top repositories by workload")).toBeVisible();
|
await expect(page.getByText("Top repositories by workload")).toBeVisible();
|
||||||
await expect(page.locator(".issue-card").first().getByText("Reject")).toBeVisible();
|
await expect(page.locator(".issue-card").first()).toBeVisible();
|
||||||
|
|
||||||
expect(apiRequests).toContain("/api/v1/latest_run");
|
expect(apiRequests).toContain("/api/v1/latest_run");
|
||||||
expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/object-types");
|
expect(apiRequests.some((request) => request.includes("/stats/object-types"))).toBe(true);
|
||||||
expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/validation");
|
expect(apiRequests.some((request) => request.includes("/stats/validation"))).toBe(true);
|
||||||
expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/reasons");
|
expect(apiRequests.some((request) => request.includes("/stats/reasons"))).toBe(true);
|
||||||
expect(apiRequests).toContain("/api/v1/runs/run_7144/repos");
|
expect(apiRequests.some((request) => request.includes("/repos?limit=8"))).toBe(true);
|
||||||
expect(apiRequests).not.toContain("/api/v1/runs/run_7144/objects");
|
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
||||||
expect(consoleErrors).toEqual([]);
|
expect(consoleErrors).toEqual([]);
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview-live.png`, fullPage: true });
|
||||||
path: "../../../../specs/develop/20260617/m3_playwright/rpki-explorer-overview-live.png",
|
|
||||||
fullPage: true
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
|
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||||
|
|
||||||
test("loads repository, publication point, and object data on demand", async ({ page }) => {
|
test("loads repository, publication point, and object data on demand", async ({ page }) => {
|
||||||
const apiRequests: string[] = [];
|
const apiRequests: string[] = [];
|
||||||
@ -24,28 +25,71 @@ test("loads repository, publication point, and object data on demand", async ({
|
|||||||
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
||||||
await expect(page.getByText("Select a repository to load its publication points.")).toBeVisible();
|
await expect(page.getByText("Select a repository to load its publication points.")).toBeVisible();
|
||||||
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(false);
|
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(false);
|
||||||
expect(apiRequests.some((request) => request.includes("/objects"))).toBe(false);
|
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /sakuya\.nat\.moe/i }).click();
|
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||||
await expect(page.getByRole("button", { name: /5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/i })).toBeVisible({ timeout: 30_000 });
|
await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
||||||
expect(apiRequests.some((request) => request.includes("/repos/0490c1fe6e4d4ae5cc354948/publication-points"))).toBe(true);
|
await expect(reposSection.getByLabel("Cursor pagination")).toContainText("Page 1");
|
||||||
expect(apiRequests.some((request) => request.includes("/objects"))).toBe(false);
|
await expect(reposSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/.+ shown/);
|
||||||
|
await reposSection.getByRole("button", { name: "Collapse repositories column" }).click();
|
||||||
|
await expect(reposSection.locator(".collapsed-panel-summary")).toContainText("Repositories");
|
||||||
|
await expect(reposSection.getByRole("button", { name: "Expand repositories column" })).toBeVisible();
|
||||||
|
await reposSection.getByRole("button", { name: "Expand repositories column" }).click();
|
||||||
|
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||||
|
await expect(reposSection.locator(".stack-row")).toHaveCount(1);
|
||||||
|
await reposSection.locator(".stack-row-select").first().click();
|
||||||
|
|
||||||
await page.getByRole("button", { name: /5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/i }).click();
|
const ppSection = page.locator('section[aria-label="Publication points"]');
|
||||||
await expect.poll(
|
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
||||||
() => apiRequests.some((request) => request.includes("/publication-points/node_10342/objects")),
|
await expect(ppSection.getByLabel("Cursor pagination")).toContainText("Page 1");
|
||||||
{ timeout: 70_000 }
|
await expect(ppSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
|
||||||
).toBe(true);
|
await ppSection.getByRole("button", { name: "Collapse publication points column" }).click();
|
||||||
|
await expect(ppSection.getByRole("button", { name: "Expand publication points column" })).toBeVisible();
|
||||||
|
await ppSection.getByRole("button", { name: "Expand publication points column" }).click();
|
||||||
|
expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(true);
|
||||||
|
expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false);
|
||||||
|
|
||||||
|
await ppSection.locator(".stack-row-select").first().click();
|
||||||
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
||||||
await expect(
|
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
|
||||||
objectsPanel.getByText(/rsync:\/\/sakuya\.nat\.moe\/repo\/NATOCA\/1\/5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/)
|
expect(apiRequests.some((request) => request.includes("/publication-points/") && request.includes("/objects?limit=50"))).toBe(true);
|
||||||
).toBeVisible({ timeout: 70_000 });
|
await expect(objectsPanel.getByRole("button", { name: "Open" }).first()).toBeVisible();
|
||||||
await expect(objectsPanel.getByText("manifest").first()).toBeVisible();
|
await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible();
|
||||||
await expect(page.getByText("limit=50")).toBeVisible();
|
await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible();
|
||||||
|
await expect(objectsPanel.getByLabel("Filter objects on current page")).toBeVisible();
|
||||||
|
await expect(objectsPanel.getByLabel("Cursor pagination")).toBeVisible();
|
||||||
|
await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText("Page 1");
|
||||||
|
await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/);
|
||||||
expect(consoleErrors).toEqual([]);
|
expect(consoleErrors).toEqual([]);
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-repository-browser.png`, fullPage: true });
|
||||||
path: "../../../../specs/develop/20260617/m4_playwright/rpki-explorer-repository-browser.png",
|
|
||||||
fullPage: true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps selected repository and publication point after opening object detail", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||||
|
|
||||||
|
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||||
|
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||||
|
await expect(reposSection.locator(".stack-row")).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
const selectedRepoUri = (await reposSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim();
|
||||||
|
await reposSection.locator(".stack-row-select").first().click();
|
||||||
|
|
||||||
|
const ppSection = page.locator('section[aria-label="Publication points"]');
|
||||||
|
await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
const selectedPpUri = (await ppSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim();
|
||||||
|
await ppSection.locator(".stack-row-select").first().click();
|
||||||
|
|
||||||
|
const objectsPanel = page.getByRole("region", { name: "Objects for publication point" });
|
||||||
|
await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 });
|
||||||
|
const selectedObjectUri = (await objectsPanel.locator(".uri-cell .copyable-value-text").first().innerText()).trim();
|
||||||
|
await objectsPanel.getByRole("button", { name: "Open" }).first().click();
|
||||||
|
await expect(page.locator(".object-detail-card")).toContainText(selectedObjectUri, { timeout: 70_000 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
||||||
|
await expect(reposSection.getByLabel("Filter repositories on current page")).toHaveValue("sakuya");
|
||||||
|
await expect(reposSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedRepoUri);
|
||||||
|
await expect(ppSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedPpUri);
|
||||||
|
await expect(objectsPanel.locator("tbody tr").first()).toContainText(selectedObjectUri);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test("opens the RPKI Explorer overview prototype", async ({ page }) => {
|
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||||
|
|
||||||
|
function collectConsoleErrors(page: import("@playwright/test").Page) {
|
||||||
const consoleErrors: string[] = [];
|
const consoleErrors: string[] = [];
|
||||||
page.on("console", (message) => {
|
page.on("console", (message) => {
|
||||||
if (message.type() === "error") {
|
if (message.type() === "error") {
|
||||||
@ -10,47 +12,72 @@ test("opens the RPKI Explorer overview prototype", async ({ page }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return consoleErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("opens the RPKI Explorer overview", async ({ page }) => {
|
||||||
|
const consoleErrors = collectConsoleErrors(page);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /Overview/i })).toBeVisible();
|
await expect(page.getByRole("button", { name: /Overview/i })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("Current run")).toContainText("latest indexed run");
|
||||||
|
await expect(page.getByPlaceholder("Exact URI lookup planned in M2")).toBeDisabled();
|
||||||
await expect(page.getByText("Top repositories by workload")).toBeVisible();
|
await expect(page.getByText("Top repositories by workload")).toBeVisible();
|
||||||
await expect(page.getByText("Recent validation issues")).toBeVisible();
|
await expect(page.getByText("Recent validation issues")).toBeVisible();
|
||||||
await expect(page.getByRole("cell", { name: /RRDP|RSYNC/i }).first()).toBeVisible();
|
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview.png`, fullPage: true });
|
||||||
await page.screenshot({
|
|
||||||
path: "../../../../specs/develop/20260617/m2_playwright/rpki-explorer-overview.png",
|
|
||||||
fullPage: true
|
|
||||||
});
|
|
||||||
expect(consoleErrors).toEqual([]);
|
expect(consoleErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opens the RPKI Explorer object detail prototype", async ({ page }) => {
|
test("collapses sidebar to icon-only navigation on desktop", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
const consoleErrors = collectConsoleErrors(page);
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
page.on("console", (message) => {
|
await page.goto("/");
|
||||||
if (message.type() === "error") {
|
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
||||||
const text = message.text();
|
|
||||||
if (!text.includes("Failed to load resource")) {
|
await page.getByRole("button", { name: "Collapse navigation" }).click();
|
||||||
consoleErrors.push(text);
|
await expect(page.locator(".app-shell")).toHaveClass(/sidebar-collapsed/);
|
||||||
}
|
await expect(page.getByRole("button", { name: "Expand navigation" })).toBeVisible();
|
||||||
}
|
await expect(page.locator(".brand-copy")).toHaveCSS("position", "absolute");
|
||||||
|
await expect(page.locator(".nav-item").first().locator("span")).toHaveCSS("position", "absolute");
|
||||||
|
await expect(page.getByRole("button", { name: "Repositories" })).toBeVisible();
|
||||||
|
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-sidebar-collapsed.png`, fullPage: true });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Repositories" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Expand navigation" }).click();
|
||||||
|
await expect(page.locator(".app-shell")).not.toHaveClass(/sidebar-collapsed/);
|
||||||
|
await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible();
|
||||||
|
expect(consoleErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("opens truthful placeholders for unfinished navigation", async ({ page }) => {
|
||||||
|
const consoleErrors = collectConsoleErrors(page);
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
for (const name of ["Publication Points", "Validation", "Exports", "Runs", "API"]) {
|
||||||
|
await page.getByRole("button", { name }).click();
|
||||||
|
await expect(page.locator("#coming-soon-heading")).toHaveText(name);
|
||||||
|
await expect(page.getByText("Coming soon", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-coming-soon.png`, fullPage: true });
|
||||||
|
expect(consoleErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("objects page starts from explicit empty state", async ({ page }) => {
|
||||||
|
const consoleErrors = collectConsoleErrors(page);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByRole("button", { name: /Objects/i }).click();
|
await page.getByRole("button", { name: /Objects/i }).click();
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 });
|
await expect(page.getByRole("heading", { name: "Object object" })).toBeVisible();
|
||||||
await expect(page.getByText("Object detail · live query service")).toBeVisible();
|
await expect(page.getByText("Search an exact URI or open an object from Repository Browser.")).toBeVisible();
|
||||||
await expect(page.getByText("File and chain checks")).toBeVisible();
|
await expect(page.locator("body")).not.toContainText("sample PP");
|
||||||
await expect(page.getByRole("tab", { name: "Validation" })).toBeVisible();
|
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-objects-empty.png`, fullPage: true });
|
||||||
await page.getByRole("tab", { name: "Validation" }).click();
|
|
||||||
await expect(page.getByText("Final status: valid")).toBeVisible();
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: "../../../../specs/develop/20260617/m2_playwright/rpki-explorer-object-detail.png",
|
|
||||||
fullPage: true
|
|
||||||
});
|
|
||||||
expect(consoleErrors).toEqual([]);
|
expect(consoleErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,21 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
|
const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e";
|
||||||
|
|
||||||
|
async function openFirstRepositoryObject(page: import("@playwright/test").Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: /Repositories/i }).click();
|
||||||
|
const reposSection = page.locator('section[aria-label="Repositories"]');
|
||||||
|
await reposSection.getByLabel("Filter repositories on current page").fill("sakuya");
|
||||||
|
await reposSection.locator(".stack-row-select").first().click();
|
||||||
|
await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click();
|
||||||
|
const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first();
|
||||||
|
await expect(row).toBeVisible({ timeout: 70_000 });
|
||||||
|
const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim();
|
||||||
|
await row.getByRole("button", { name: "Open" }).click();
|
||||||
|
await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 });
|
||||||
|
}
|
||||||
|
|
||||||
test("supports URI search and reports raw/export workflow status", async ({ page }) => {
|
test("supports URI search and reports raw/export workflow status", async ({ page }) => {
|
||||||
const apiRequests: string[] = [];
|
const apiRequests: string[] = [];
|
||||||
@ -11,9 +26,7 @@ test("supports URI search and reports raw/export workflow status", async ({ page
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
await openFirstRepositoryObject(page);
|
||||||
await page.getByRole("button", { name: /Objects/i }).click();
|
|
||||||
await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 });
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Use selected URI" }).click();
|
await page.getByRole("button", { name: "Use selected URI" }).click();
|
||||||
await page.getByPlaceholder("Exact URI lookup...").press("Enter");
|
await page.getByPlaceholder("Exact URI lookup...").press("Enter");
|
||||||
@ -23,18 +36,17 @@ test("supports URI search and reports raw/export workflow status", async ({ page
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Download raw" }).click();
|
await page.getByRole("button", { name: "Download raw" }).click();
|
||||||
await expect(page.getByText("Raw download failed: repo-bytes db is not configured for raw download")).toBeVisible({ timeout: 30_000 });
|
await expect.poll(
|
||||||
expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/raw"))).toBe(true);
|
() => apiRequests.some((request) => request.includes("/raw")),
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Export object" }).click();
|
await page.getByRole("button", { name: "Export object" }).click();
|
||||||
await expect(page.getByText("Object export failed: repo-bytes db is required for export jobs")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/Object export job|Object export failed/)).toBeVisible({ timeout: 30_000 });
|
||||||
expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/run_7144/exports"))).toBe(true);
|
expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/") && request.includes("/exports"))).toBe(true);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Export selected PP" }).click();
|
await page.getByRole("button", { name: "Export selected PP" }).click();
|
||||||
await expect(page.getByText("PP export failed: repo-bytes db is required for export jobs")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/PP export job|PP export failed/)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-workflows.png`, fullPage: true });
|
||||||
path: "../../../../specs/develop/20260617/m6_playwright/rpki-explorer-workflows.png",
|
|
||||||
fullPage: true
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user