diff --git a/ui/rpki-explorer/src/app/App.tsx b/ui/rpki-explorer/src/app/App.tsx index 69ceb9b..4d571c4 100644 --- a/ui/rpki-explorer/src/app/App.tsx +++ b/ui/rpki-explorer/src/app/App.tsx @@ -1,9 +1,13 @@ import { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { useState } from "react"; import { Shell } from "../components/layout/Shell"; import { ObjectDetailPage } from "../features/object-detail/ObjectDetailPage"; import { OverviewPage } from "../features/overview/OverviewPage"; import { RepositoriesPage } from "../features/repositories/RepositoriesPage"; +import { createRepositoryBrowserState } from "../features/repositories/repositoryBrowserState"; + +type ViewId = "overview" | "repositories" | "publication-points" | "objects" | "validation" | "exports" | "runs" | "api"; const navigationItems = [ { id: "overview", label: "Overview", icon: Home }, @@ -14,22 +18,86 @@ const navigationItems = [ { id: "exports", label: "Exports", icon: PackageOpen }, { id: "runs", label: "Runs", icon: Activity }, { id: "api", label: "API", icon: FileSearch } -]; +] satisfies Array<{ id: ViewId; label: string; icon: LucideIcon }>; + +const comingSoonCopy: Record, { title: string; description: string; alternative: string }> = { + "publication-points": { + title: "Publication Points", + description: "Dedicated publication point analytics are not implemented yet.", + alternative: "Use Repositories to drill down from repository to publication point and object rows." + }, + validation: { + title: "Validation", + description: "A dedicated validation investigation workspace is planned but not available in this MVP.", + alternative: "Use Overview for reason summaries and Object Detail for per-object validation evidence." + }, + exports: { + title: "Exports", + description: "A standalone export job browser is planned but not available yet.", + alternative: "Use Object Detail to trigger object or publication point export workflows." + }, + runs: { + title: "Runs", + description: "Historical run navigation is not implemented in the current Explorer UI.", + alternative: "The current view always uses the latest indexed run from query service." + }, + api: { + title: "API", + description: "The API reference page is planned but not implemented yet.", + alternative: "Use the project README and query service endpoints while the UI reference page is pending." + } +}; export function App() { - const [activeView, setActiveView] = useState("overview"); + const [activeView, setActiveView] = useState("overview"); const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState(null); + const [repositoryBrowserState, setRepositoryBrowserState] = useState(createRepositoryBrowserState); const openObject = (objectInstanceId: string) => { setSelectedObjectInstanceId(objectInstanceId); setActiveView("objects"); }; + const renderActiveView = () => { + if (activeView === "objects") { + return ; + } + if (activeView === "repositories") { + return ( + + ); + } + if (activeView === "overview") { + return ; + } + return ; + }; + return ( - {activeView === "objects" ? : null} - {activeView === "repositories" ? : null} - {activeView !== "objects" && activeView !== "repositories" ? : null} + {renderActiveView()} ); } + +function ComingSoonPage({ title, description, alternative }: { title: string; description: string; alternative: string }) { + return ( +
+
+
+

Planned workspace

+

{title}

+

{description}

+
+
+ Coming soon + {alternative} +
+
+
+ ); +} diff --git a/ui/rpki-explorer/src/components/common/CopyableValue.tsx b/ui/rpki-explorer/src/components/common/CopyableValue.tsx new file mode 100644 index 0000000..c659830 --- /dev/null +++ b/ui/rpki-explorer/src/components/common/CopyableValue.tsx @@ -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(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 ( + + setTooltipPosition(null)} + onFocus={(event) => showTooltip(event.currentTarget)} + onMouseEnter={(event) => showTooltip(event.currentTarget)} + onMouseLeave={() => setTooltipPosition(null)} + tabIndex={0} + title={value} + > + {displayValue ?? value} + + + {tooltipPosition ? ( + + {value} + + ) : null} + {copyState !== "idle" ? {copyState === "copied" ? "Copied" : "Copy failed"} : null} + + ); +} + +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); + } +} diff --git a/ui/rpki-explorer/src/components/common/CursorPager.tsx b/ui/rpki-explorer/src/components/common/CursorPager.tsx new file mode 100644 index 0000000..e8265f5 --- /dev/null +++ b/ui/rpki-explorer/src/components/common/CursorPager.tsx @@ -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 ( +
+ + + {label ?? `Page ${pageNumber}`} + {rangeLabel ? {rangeLabel} : null} + + +
+ ); +} diff --git a/ui/rpki-explorer/src/components/common/cursorPaging.ts b/ui/rpki-explorer/src/components/common/cursorPaging.ts new file mode 100644 index 0000000..3b547a9 --- /dev/null +++ b/ui/rpki-explorer/src/components/common/cursorPaging.ts @@ -0,0 +1,24 @@ +export interface PageState { + cursor: string | null; + previous: Array; +} + +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) + }; +} diff --git a/ui/rpki-explorer/src/components/layout/Shell.tsx b/ui/rpki-explorer/src/components/layout/Shell.tsx index 4cac951..35e8257 100644 --- a/ui/rpki-explorer/src/components/layout/Shell.tsx +++ b/ui/rpki-explorer/src/components/layout/Shell.tsx @@ -1,26 +1,29 @@ import type { LucideIcon } from "lucide-react"; -import { BarChart3, ChevronDown, CircleHelp, Search, Sun } from "lucide-react"; +import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react"; import type { PropsWithChildren } from "react"; +import { useState } from "react"; -export interface NavigationItem { - id: string; +export interface NavigationItem { + id: Id; label: string; icon: LucideIcon; } -interface ShellProps extends PropsWithChildren { - activeView: string; - navigationItems: NavigationItem[]; - onNavigate: (id: string) => void; +interface ShellProps extends PropsWithChildren { + activeView: Id; + navigationItems: Array>; + onNavigate: (id: Id) => void; } -export function Shell({ activeView, navigationItems, onNavigate, children }: ShellProps) { +export function Shell({ activeView, navigationItems, onNavigate, children }: ShellProps) { + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + return ( -
+
-
+
Run - latest run -
-
Ready
- - - +
UI Ready
{children} diff --git a/ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx b/ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx index 6be8291..9520734 100644 --- a/ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx +++ b/ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { Check, Copy, Download, FileText, GitBranch, Loader2, Search, ShieldCheck } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { normalizeApiError } from "../../api/client"; +import { CopyableValue } from "../../components/common/CopyableValue"; import { createObjectSetExport, createPublicationPointExport, @@ -14,7 +15,6 @@ import { getObjectParsed, getObjectValidation, listManifestFiles, - listObjectsForPublicationPoint, listRevokedCertificates, type ChainEdgeRecord, type ObjectInstanceRecord, @@ -22,7 +22,6 @@ import { type ValidationExplainRecord } from "../../api/queryService"; -const SAMPLE_PP_ID = "node_10342"; const tabs = ["Parsed", "Validation", "Chain", "Manifest files", "Revoked certs"] as const; type ObjectTab = (typeof tabs)[number]; @@ -33,30 +32,16 @@ interface ObjectDetailPageProps { export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) { const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState(initialObjectInstanceId ?? null); const [activeTab, setActiveTab] = useState("Validation"); + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); const [searchUri, setSearchUri] = useState(""); const latestRunQuery = useQuery({ queryKey: ["object-detail-latest-run"], queryFn: getLatestRun }); const runId = latestRunQuery.data?.data.runId ?? "latest"; - const objectsQuery = useQuery({ - enabled: Boolean(latestRunQuery.data?.data.runId), - queryKey: ["object-detail-sample-objects", runId, SAMPLE_PP_ID], - queryFn: () => listObjectsForPublicationPoint(runId, SAMPLE_PP_ID, 50), - staleTime: 5 * 60 * 1000 - }); - - const objectRows = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]); - const objectOptions = useMemo( - () => objectRows.map((object) => ({ - object, - label: `${labelObjectType(object.objectType)} ${shortName(object.uri)}` - })), - [objectRows] - ); useEffect(() => { - if (!selectedObjectInstanceId && objectRows.length > 0) { - const preferred = objectRows.find((item) => item.objectType === "manifest") ?? objectRows[0]; - setSelectedObjectInstanceId(preferred.objectInstanceId); + if (initialObjectInstanceId && initialObjectInstanceId !== selectedObjectInstanceId) { + setSelectedObjectInstanceId(initialObjectInstanceId); + setActiveTab("Validation"); } - }, [objectRows, selectedObjectInstanceId]); + }, [initialObjectInstanceId, selectedObjectInstanceId]); const objectQuery = useQuery({ enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), @@ -64,6 +49,7 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""), staleTime: 5 * 60 * 1000 }); + const selectedObject = objectQuery.data?.data ?? null; const validationQuery = useQuery({ enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId), queryKey: ["object-detail-validation", runId, selectedObjectInstanceId], @@ -113,44 +99,43 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : []) }); const ppExportMutation = useMutation({ - mutationFn: () => createPublicationPointExport(runId, selectedObject?.ppId ?? SAMPLE_PP_ID) + mutationFn: () => { + if (!selectedObject?.ppId) { + throw new Error("No publication point selected"); + } + return createPublicationPointExport(runId, selectedObject.ppId); + } }); - const selectedObject = objectQuery.data?.data ?? objectRows.find((item) => item.objectInstanceId === selectedObjectInstanceId) ?? null; + useEffect(() => { + if (copyState === "idle") { + return; + } + const timeout = window.setTimeout(() => setCopyState("idle"), 1800); + return () => window.clearTimeout(timeout); + }, [copyState]); + + const copySelectedUri = async () => { + if (!selectedObject?.uri) { + return; + } + try { + await navigator.clipboard.writeText(selectedObject.uri); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; + const validation = validationQuery.data?.data ?? null; - const apiError = latestRunQuery.error ?? objectsQuery.error ?? objectQuery.error ?? validationQuery.error; + const apiError = latestRunQuery.error ?? objectQuery.error ?? validationQuery.error; return (
{apiError ? : null} -
- - -
-
-
+
+
- - -
); @@ -271,10 +250,10 @@ export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPagePr function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) { return (
- + - - + +
@@ -398,7 +377,7 @@ function ChainPanel({ edges, isFetching, error }: { edges: ChainEdgeRecord[]; is
{edge.relation} {edge.status} - {edge.toUri} +
))}
@@ -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 (
{label} - {value} + {copyable ? : value}
); } @@ -518,8 +497,8 @@ function labelObjectType(type: string) { return labels[type] ?? type.toUpperCase(); } -function shortName(uri: string) { - return uri.split("/").filter(Boolean).at(-1) ?? uri; +function tabId(tab: ObjectTab) { + return tab.toLowerCase().replaceAll(" ", "-"); } function issuesText(issues: { summary?: string }[] | undefined) { diff --git a/ui/rpki-explorer/src/features/overview/OverviewPage.tsx b/ui/rpki-explorer/src/features/overview/OverviewPage.tsx index d4b585a..33bdc7c 100644 --- a/ui/rpki-explorer/src/features/overview/OverviewPage.tsx +++ b/ui/rpki-explorer/src/features/overview/OverviewPage.tsx @@ -12,6 +12,7 @@ import { type RunRecord } from "../../api/queryService"; import { normalizeApiError } from "../../api/client"; +import { CopyableValue } from "../../components/common/CopyableValue"; const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"]; const objectTypeLabels: Record = { @@ -58,7 +59,7 @@ export function OverviewPage() { const objectRows = objectTypesQuery.data?.data ? objectRowsFromStats(objectTypesQuery.data.data) : objectTypeRows; const validationRows = validationQuery.data?.data ? validationRowsFromStats(validationQuery.data.data) : validationSlices; const validPercent = percent(validationQuery.data?.data?.ok ?? validationRows[0]?.value ?? 0, sumValues(validationQuery.data?.data) || 100); - const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : topRepositories; + const repoRows = reposQuery.data?.data ? reposFromApi(reposQuery.data.data) : reposFromFixtures(); const issueRows = reasonsQuery.data?.data ? issuesFromReasons(reasonsQuery.data.data) : validationIssues; const statusText = liveRun ? statusForRun(liveRun) : "Static fixture"; const statusSubtext = liveRun ? `run ${liveRun.runSeq ?? liveRun.runId} · ${formatDuration(liveRun.wallMs)}` : "query service not connected"; @@ -172,7 +173,7 @@ export function OverviewPage() { {repoRows.map((repo) => ( - + {repo.host} {repo.transport} {repo.duration} @@ -202,7 +203,7 @@ export function OverviewPage() { {issue.type}

{issue.reason}

- {issue.uri} + {issue.repo} ))} @@ -253,6 +254,7 @@ function reposFromApi(repos: RepositoryRecord[]) { .slice(0, 6) .map((repo) => ({ host: repo.host, + id: repo.repoId, transport: repo.transport.toUpperCase(), duration: formatDuration(repo.syncDurationMsTotal), objects: formatNumber(repo.objects), @@ -260,6 +262,17 @@ function reposFromApi(repos: RepositoryRecord[]) { })); } +function reposFromFixtures() { + return topRepositories.map((repo, index) => ({ + ...repo, + id: fallbackRepoId(repo, index) + })); +} + +function fallbackRepoId(repo: { host: string; transport: string; duration: string; objects: string }, index: number) { + return `${repo.host}-${repo.transport}-${repo.duration}-${repo.objects}-${index}`; +} + function issuesFromReasons(reasons: CountsByKey) { const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left); if (entries.length === 0) { diff --git a/ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx b/ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx index 2e4676f..87d52e0 100644 --- a/ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx +++ b/ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx @@ -1,51 +1,101 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronRight, Database, FileCode2, GitBranch, Loader2 } from "lucide-react"; -import type { ReactNode } from "react"; -import { useState } from "react"; +import { ChevronRight, Database, FileCode2, GitBranch, Loader2, PanelLeftClose, PanelRightClose } from "lucide-react"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { useMemo } from "react"; +import { CopyableValue } from "../../components/common/CopyableValue"; +import { CursorPager } from "../../components/common/CursorPager"; +import { advancePage, previousPage, type PageState } from "../../components/common/cursorPaging"; +import { createEmptyPageState, type RepositoryBrowserState } from "./repositoryBrowserState"; import { getLatestRun, listObjectsForPublicationPoint, listPublicationPointsForRepo, listRepos, - type ObjectInstanceRecord + type ObjectInstanceRecord, + type PublicationPointRecord, + type RepositoryRecord } from "../../api/queryService"; import { normalizeApiError } from "../../api/client"; +const REPO_PAGE_SIZE = 50; +const PP_PAGE_SIZE = 50; +const OBJECT_PAGE_SIZE = 50; + interface RepositoriesPageProps { + browserState: RepositoryBrowserState; + onBrowserStateChange: Dispatch>; onOpenObject?: (objectInstanceId: string) => void; } -export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) { - const [selectedRepoId, setSelectedRepoId] = useState(null); - const [selectedPpId, setSelectedPpId] = useState(null); +export function RepositoriesPage({ browserState, onBrowserStateChange, onOpenObject }: RepositoriesPageProps) { + const { + selectedRepoId, + selectedPpId, + repoFilter, + ppFilter, + objectFilter, + repoPage, + ppPage, + objectPage, + isRepoPanelCollapsed, + isPpPanelCollapsed + } = browserState; + const setRepoFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, repoFilter: value })); + const setPpFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, ppFilter: value })); + const setObjectFilter = (value: string) => onBrowserStateChange((state) => ({ ...state, objectFilter: value })); + const setRepoPage = (updater: SetStateAction) => { + onBrowserStateChange((state) => ({ + ...state, + repoPage: typeof updater === "function" ? updater(state.repoPage) : updater + })); + }; + const setPpPage = (updater: SetStateAction) => { + onBrowserStateChange((state) => ({ + ...state, + ppPage: typeof updater === "function" ? updater(state.ppPage) : updater + })); + }; + const setObjectPage = (updater: SetStateAction) => { + onBrowserStateChange((state) => ({ + ...state, + objectPage: typeof updater === "function" ? updater(state.objectPage) : updater + })); + }; const latestRunQuery = useQuery({ queryKey: ["repositories-latest-run"], queryFn: getLatestRun }); const runId = latestRunQuery.data?.data.runId ?? "latest"; const reposQuery = useQuery({ - queryKey: ["repositories", runId], - queryFn: () => listRepos(runId, 50), + queryKey: ["repositories", runId, repoPage.cursor], + queryFn: () => listRepos(runId, REPO_PAGE_SIZE, repoPage.cursor), enabled: Boolean(latestRunQuery.data?.data.runId) }); - const repos = reposQuery.data?.data ?? []; + const repos = useMemo(() => reposQuery.data?.data ?? [], [reposQuery.data?.data]); + const filteredRepos = useMemo(() => filterRepositories(repos, repoFilter), [repoFilter, repos]); const selectedRepo = selectedRepoId ? repos.find((repo) => repo.repoId === selectedRepoId) ?? null : null; const ppsQuery = useQuery({ - queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? ""], - queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", 50), + queryKey: ["repository-publication-points", runId, selectedRepo?.repoId ?? "", ppPage.cursor], + queryFn: () => listPublicationPointsForRepo(runId, selectedRepo?.repoId ?? "", PP_PAGE_SIZE, ppPage.cursor), enabled: Boolean(selectedRepo?.repoId) }); - const publicationPoints = ppsQuery.data?.data ?? []; + const publicationPoints = useMemo(() => ppsQuery.data?.data ?? [], [ppsQuery.data?.data]); + const filteredPublicationPoints = useMemo(() => filterPublicationPoints(publicationPoints, ppFilter), [ppFilter, publicationPoints]); const selectedPp = selectedPpId ? publicationPoints.find((pp) => pp.ppId === selectedPpId) ?? null : null; const objectsQuery = useQuery({ - queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? ""], - queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", 50), + queryKey: ["publication-point-objects", runId, selectedPp?.ppId ?? "", objectPage.cursor], + queryFn: () => listObjectsForPublicationPoint(runId, selectedPp?.ppId ?? "", OBJECT_PAGE_SIZE, objectPage.cursor), enabled: Boolean(selectedPp?.ppId) }); + const objects = useMemo(() => objectsQuery.data?.data ?? [], [objectsQuery.data?.data]); + const filteredObjects = useMemo(() => filterObjects(objects, objectFilter), [objectFilter, objects]); + const repoTotal = reposQuery.data?.page?.nextCursor ? undefined : repoPage.previous.length * REPO_PAGE_SIZE + repos.length; + const ppTotal = selectedRepo?.publicationPoints; + const objectTotal = selectedPp?.objects; const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error; @@ -73,97 +123,239 @@ export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) { ) : null} -
-
- } label="Repositories" meta={reposQuery.isFetching ? "loading" : `${repos.length} shown`} /> +
+
+ } + 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 ? ( + } label="Repositories" value={repoTotal ?? repos.length} /> + ) : ( + <> +
- {repos.map((repo) => ( - + {filteredRepos.map((repo) => ( +
+ + +
))}
+ 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)} + /> + + )}
-
+
} + isCollapsed={isPpPanelCollapsed} label="Publication Points" - meta={ppsQuery.isFetching ? "loading" : `${publicationPoints.length} shown`} + meta={ppsQuery.isFetching ? "loading" : selectedRepo ? rangeLabel(ppPage.previous.length + 1, PP_PAGE_SIZE, publicationPoints.length, ppTotal, ppFilter, filteredPublicationPoints.length) : "select repo"} + onToggleCollapse={() => onBrowserStateChange((state) => ({ ...state, isPpPanelCollapsed: !state.isPpPanelCollapsed }))} + toggleLabel={isPpPanelCollapsed ? "Expand publication points column" : "Collapse publication points column"} /> + {isPpPanelCollapsed ? ( + } label="Publication Points" value={ppTotal ?? publicationPoints.length} /> + ) : ( + <> {!selectedRepo ? (
Select a repository to load its publication points.
) : null} + {ppsQuery.isFetching ? : null}
- {publicationPoints.map((pp) => ( - + {filteredPublicationPoints.map((pp) => ( +
+ + + {pp.manifestRsyncUri ? : null} +
))}
+ 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} + /> + + )}
} 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 ? (
Select a publication point to load its objects.
) : null} + {objectsQuery.isFetching ? : null} {selectedPp && !objectsQuery.isFetching ? ( - + ) : null} -
- Default query uses limit=50. Next cursor: {objectsQuery.data?.page?.nextCursor ?? "none"} -
+ 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} + />
); } -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 (

{label}

{label}

- - {icon} - {meta} - +
+ + {icon} + {meta} + + {onToggleCollapse ? ( + + ) : null} +
); } +function CollapsedPanelSummary({ icon, label, value }: { icon: ReactNode; label: string; value: number }) { + return ( +
+ {icon} + {label} + {value.toLocaleString()} +
+ ); +} + +function CurrentPageFilter({ + disabled, + label, + onChange, + placeholder, + value +}: { + disabled?: boolean; + label: string; + onChange: (value: string) => void; + placeholder: string; + value: string; +}) { + return ( + + ); +} + function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[]; onOpenObject?: (objectInstanceId: string) => void }) { if (objects.length === 0) { return
No objects returned for this publication point.
; @@ -184,8 +376,8 @@ function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[ {objects.map((object) => ( {object.objectType} - {object.uri} - {object.sha256.slice(0, 12)}... + + {object.sourceSection} @@ -222,3 +414,54 @@ function LoadingLine() { function shortName(uri: string) { return uri.split("/").filter(Boolean).at(-1) ?? uri; } + +function shortHash(value: string, prefixLength: number) { + return `${value.slice(0, prefixLength)}...`; +} + +function rangeLabel(pageNumber: number, pageSize: number, pageRowCount: number, total: number | undefined, filter: string, visibleRowCount = pageRowCount) { + const start = pageRowCount === 0 ? 0 : (pageNumber - 1) * pageSize + 1; + const end = pageRowCount === 0 ? 0 : start + pageRowCount - 1; + const totalText = total === undefined ? "unknown" : total.toLocaleString(); + const filterText = filter.trim() ? ` · ${visibleRowCount.toLocaleString()}/${pageRowCount.toLocaleString()} matched on page` : ""; + return `${start.toLocaleString()}-${end.toLocaleString()}/${totalText} shown${filterText}`; +} + +function matchesNeedle(values: Array, 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)); +} diff --git a/ui/rpki-explorer/src/features/repositories/repositoryBrowserState.ts b/ui/rpki-explorer/src/features/repositories/repositoryBrowserState.ts new file mode 100644 index 0000000..304a3fe --- /dev/null +++ b/ui/rpki-explorer/src/features/repositories/repositoryBrowserState.ts @@ -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: [] }; +} diff --git a/ui/rpki-explorer/src/styles/globals.css b/ui/rpki-explorer/src/styles/globals.css index f8c75f9..decfced 100644 --- a/ui/rpki-explorer/src/styles/globals.css +++ b/ui/rpki-explorer/src/styles/globals.css @@ -15,6 +15,7 @@ body { margin: 0; min-width: 320px; min-height: 100vh; + overflow-x: hidden; background: #f7faff; } @@ -38,12 +39,20 @@ input:focus-visible { grid-template-columns: 230px minmax(0, 1fr); min-height: 100vh; background: linear-gradient(180deg, #ffffff 0%, #f4f8fe 100%); + transition: grid-template-columns 180ms ease; +} + +.app-shell.sidebar-collapsed { + grid-template-columns: 76px minmax(0, 1fr); } .sidebar { position: sticky; + display: flex; + flex-direction: column; top: 0; height: 100vh; + overflow: hidden; border-right: 1px solid #dbe4f0; background: rgba(255, 255, 255, 0.88); color: #10213f; @@ -58,7 +67,13 @@ input:focus-visible { padding: 0 10px; } +.brand-copy { + min-width: 0; + transition: opacity 160ms ease; +} + .brand-mark { + flex: 0 0 36px; display: grid; width: 36px; height: 36px; @@ -85,6 +100,33 @@ input:focus-visible { text-transform: uppercase; } +.sidebar-toggle { + display: flex; + width: 100%; + align-items: center; + justify-content: flex-start; + gap: 10px; + border: 1px solid #d2deee; + border-radius: 9px; + background: #ffffff; + color: #24466f; + font-weight: 800; + margin-top: auto; + padding: 9px 12px; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.sidebar-toggle:hover, +.sidebar-toggle:focus-visible { + border-color: #b7d2fb; + background: #eaf3ff; + color: #0759d7; +} + +.sidebar-toggle svg { + flex: 0 0 auto; +} + .eyebrow { margin-bottom: 8px; color: #60718e; @@ -97,6 +139,7 @@ input:focus-visible { .nav-list { display: grid; gap: 8px; + margin-bottom: 16px; } .nav-item { @@ -110,6 +153,7 @@ input:focus-visible { background: transparent; color: #122544; text-align: left; + transition: background 160ms ease, color 160ms ease; } .nav-item.active, @@ -119,6 +163,7 @@ input:focus-visible { } .nav-item svg { + flex: 0 0 auto; color: #24466f; } @@ -127,12 +172,51 @@ input:focus-visible { color: #0759d7; } +.sidebar-collapsed .sidebar { + padding-inline: 12px; +} + +.sidebar-collapsed .brand { + justify-content: center; + margin-bottom: 18px; + padding: 0; +} + +.sidebar-collapsed .brand-copy, +.sidebar-collapsed .nav-item span, +.sidebar-collapsed .sidebar-toggle span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.sidebar-collapsed .sidebar-toggle, +.sidebar-collapsed .nav-item { + justify-content: center; + padding-inline: 0; +} + +.sidebar-collapsed .sidebar-toggle { + width: 42px; + height: 42px; + margin-top: auto; + margin-inline: auto; +} + +.sidebar-collapsed .nav-item { + height: 44px; +} + .main-panel { min-width: 0; } .topbar { display: flex; + min-width: 0; min-height: 76px; align-items: center; justify-content: space-between; @@ -165,6 +249,7 @@ h3 { display: flex; align-items: center; gap: 18px; + max-width: 100%; min-width: 206px; border: 1px solid #d2deee; border-radius: 9px; @@ -185,6 +270,7 @@ h3 { .topbar-actions { display: flex; flex: 1; + min-width: 0; align-items: center; justify-content: flex-end; gap: 14px; @@ -194,6 +280,7 @@ h3 { display: flex; align-items: center; gap: 10px; + min-width: 0; width: min(740px, 100%); border: 1px solid #d2deee; border-radius: 9px; @@ -265,6 +352,7 @@ h3 { .hero-dashboard { display: flex; + min-width: 0; align-items: center; justify-content: space-between; gap: 20px; @@ -287,6 +375,7 @@ h3 { .hero-status { display: flex; + max-width: 100%; min-width: 230px; align-items: center; gap: 12px; @@ -391,8 +480,9 @@ h3 { .dashboard-grid { display: grid; - grid-template-columns: minmax(360px, 0.96fr) minmax(420px, 1.04fr) minmax(360px, 0.92fr); + grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr) minmax(0, 0.92fr); gap: 16px; + min-width: 0; } .panel, @@ -402,6 +492,7 @@ h3 { .tabs, .object-browser, .object-detail-card { + min-width: 0; border: 1px solid #dbe4f0; border-radius: 14px; background: white; @@ -409,10 +500,15 @@ h3 { } .panel { + overflow-x: visible; min-width: 0; padding: 18px; } +.table-panel { + overflow-x: auto; +} + .wide-panel { grid-column: span 2; } @@ -438,16 +534,101 @@ h3 { font-weight: 800; } +.panel-heading-actions { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.panel-collapse-button { + display: inline-grid; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid #d2deee; + border-radius: 8px; + background: white; + color: #24466f; +} + +.panel-collapse-button:hover, +.panel-collapse-button:focus-visible { + border-color: #a9c8f6; + background: #eaf3ff; + color: #0759d7; +} + .repo-browser-grid { display: grid; grid-template-columns: minmax(300px, 0.85fr) minmax(340px, 1fr) minmax(520px, 1.35fr); gap: 16px; + min-width: 0; +} + +.repo-browser-grid.repo-panel-collapsed { + grid-template-columns: 76px minmax(340px, 1fr) minmax(520px, 1.35fr); +} + +.repo-browser-grid.pp-panel-collapsed { + grid-template-columns: minmax(300px, 0.85fr) 76px minmax(520px, 1.35fr); +} + +.repo-browser-grid.repo-panel-collapsed.pp-panel-collapsed { + grid-template-columns: 76px 76px minmax(520px, 1fr); } .list-panel { min-height: 640px; } +.collapsible-list-panel { + min-width: 0; +} + +.repo-browser-grid.repo-panel-collapsed .repo-list-panel, +.repo-browser-grid.pp-panel-collapsed .pp-list-panel { + padding: 12px; +} + +.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-heading, +.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-heading { + align-items: center; + flex-direction: column; +} + +.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-heading > div:first-child, +.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-heading > div:first-child, +.repo-browser-grid.repo-panel-collapsed .repo-list-panel .panel-meta, +.repo-browser-grid.pp-panel-collapsed .pp-list-panel .panel-meta { + display: none; +} + +.collapsed-panel-summary { + display: grid; + min-height: 520px; + place-items: center; + align-content: center; + gap: 10px; + color: #24466f; + text-align: center; +} + +.collapsed-panel-summary strong { + writing-mode: vertical-rl; + color: #07142d; + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.collapsed-panel-summary span { + color: #0759d7; + font-size: 12px; + font-weight: 850; +} + .stack-list { display: grid; gap: 8px; @@ -458,9 +639,8 @@ h3 { .stack-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: 10px; - align-items: center; + grid-template-columns: minmax(0, 1fr); + gap: 8px; width: 100%; border: 1px solid #e3ebf5; border-radius: 10px; @@ -470,27 +650,40 @@ h3 { text-align: left; } +.stack-row-select { + display: grid; + width: 100%; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; + border: 0; + background: transparent; + color: inherit; + padding: 0; + text-align: left; +} + .stack-row.active, .stack-row:hover { border-color: #b7d2fb; background: #eaf3ff; } -.stack-row strong, -.stack-row small { +.stack-row-select strong, +.stack-row-select small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.stack-row small { +.stack-row-select small { margin-top: 4px; color: #60718e; font-size: 12px; } -.stack-row em { +.stack-row-select em { color: #0759d7; font-size: 12px; font-style: normal; @@ -498,6 +691,7 @@ h3 { } .objects-live-panel { + overflow-x: auto; min-width: 0; } @@ -518,6 +712,163 @@ h3 { padding: 6px 9px; } +.copyable-value { + position: relative; + display: inline-flex; + max-width: 100%; + min-width: 0; + align-items: center; + gap: 5px; + vertical-align: middle; +} + +.copyable-value-text { + display: inline-block; + max-width: min(100%, 360px); + min-width: 0; + overflow: hidden; + color: inherit; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inline-copyable { + width: 100%; +} + +.inline-copyable .copyable-value-text { + max-width: min(100%, 520px); +} + +.stack-row-copy { + width: 100%; + color: #60718e; + font-size: 12px; +} + +.stack-row-copy.secondary { + color: #24466f; +} + +.stack-row-copy .copyable-value-text { + max-width: 100%; +} + +.copy-icon-button { + display: inline-grid; + flex: 0 0 auto; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid #d2deee; + border-radius: 7px; + background: white; + color: #0b5bd3; + padding: 0; +} + +.copy-icon-button:hover, +.copy-icon-button:focus-visible { + border-color: #a9c8f6; + background: #eaf3ff; +} + +.copy-state-text { + color: #047857; + font-size: 11px; + font-weight: 850; +} + +.copyable-tooltip { + position: fixed; + z-index: 1000; + max-width: min(760px, calc(100vw - 32px)); + transform: translate(-50%, -100%); + border: 1px solid #b7d2fb; + border-radius: 10px; + background: #07142d; + box-shadow: 0 16px 42px rgba(7, 20, 45, 0.22); + color: white; + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; + padding: 9px 11px; + pointer-events: none; + white-space: normal; +} + +.current-page-filter { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +.current-page-filter span { + color: #60718e; + font-size: 11px; + font-weight: 850; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.current-page-filter input { + width: 100%; + border: 1px solid #d2deee; + border-radius: 8px; + background: white; + color: #0f172a; + padding: 9px 10px; +} + +.current-page-filter input:disabled { + background: #f7faff; + color: #94a3b8; +} + +.cursor-pager { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: space-between; + margin-top: 12px; + color: #60718e; + font-size: 12px; +} + +.cursor-pager span { + display: grid; + gap: 2px; + min-width: 160px; + color: #122544; + font-weight: 850; + text-align: center; +} + +.cursor-pager small { + color: #60718e; + font-size: 11px; + font-weight: 750; +} + +.cursor-pager button { + border: 1px solid #d2deee; + border-radius: 7px; + background: white; + color: #0b5bd3; + font-weight: 800; + padding: 7px 10px; +} + +.cursor-pager button:disabled, +.ghost-button:disabled, +.primary-button:disabled, +.table-link-button:disabled, +.filter-pill:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .pagination-note, .empty-state, .loading-line { @@ -639,6 +990,7 @@ h3 { table { width: 100%; + min-width: 620px; border-collapse: collapse; } @@ -693,6 +1045,8 @@ td { } .issue-card { + min-width: 0; + max-width: 100%; border-bottom: 1px solid #e3ebf5; padding: 14px 0; } @@ -703,17 +1057,23 @@ td { .issue-card div { display: flex; + min-width: 0; + max-width: 100%; + flex-wrap: wrap; align-items: center; gap: 8px; } .issue-card p { + max-width: 100%; + overflow-wrap: anywhere; margin: 9px 0; color: #122544; } .issue-card code { display: block; + max-width: 100%; overflow: hidden; color: #53657f; font-size: 12px; @@ -723,6 +1083,8 @@ td { .issue-card small { display: block; + max-width: 100%; + overflow-wrap: anywhere; margin-top: 7px; color: #60718e; } @@ -774,6 +1136,40 @@ td { grid-template-columns: 248px minmax(350px, 1fr) minmax(330px, 0.9fr); min-height: calc(100vh - 76px); gap: 0; + min-width: 0; +} + +.object-detail-only-layout { + min-height: calc(100vh - 76px); + min-width: 0; + padding: 18px; +} + +.object-detail-card-expanded { + width: min(100%, 1120px); + margin: 0 auto; +} + +.object-detail-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid #dbe4f0; + margin-bottom: 18px; + padding-bottom: 14px; +} + +.object-detail-toolbar .search-box { + flex: 1 1 420px; + max-width: 580px; + padding: 9px 12px; +} + +.object-detail-toolbar strong { + color: #24466f; + font-size: 13px; } .repository-tree { @@ -818,17 +1214,27 @@ td { } .object-picker { - display: block; + display: grid; + gap: 6px; width: calc(100% - 18px); +} + +.object-picker-select { + display: block; + width: 100%; + border: 0; + background: transparent; + color: inherit; + padding: 0; text-align: left; } -.object-picker strong, -.object-picker span { +.object-picker-select strong, +.object-picker-select span { display: block; } -.object-picker span { +.object-picker-select span { overflow: hidden; margin-top: 4px; color: #60718e; @@ -842,7 +1248,8 @@ td { } .object-browser { - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; border-top: 0; border-bottom: 0; border-radius: 0; @@ -851,6 +1258,7 @@ td { .browser-toolbar { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; @@ -859,6 +1267,7 @@ td { } .browser-toolbar .search-box { + flex: 1 1 220px; max-width: 240px; padding: 9px 12px; } @@ -885,14 +1294,26 @@ td { font-weight: 850; } +.object-row-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + .object-detail-card { align-self: start; margin: 14px; padding: 18px; } +.object-detail-card.object-detail-card-expanded { + margin: 0 auto; +} + .object-hero { display: flex; + flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 18px; @@ -902,6 +1323,11 @@ td { padding: 0 0 14px; } +.object-hero > div:first-child { + flex: 1 1 220px; + min-width: 0; +} + .object-hero p { overflow-wrap: anywhere; color: #53657f; @@ -910,9 +1336,12 @@ td { .object-actions { display: flex; + flex: 0 1 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; + max-width: 100%; + min-width: 0; } .object-meta-grid { @@ -948,6 +1377,7 @@ td { .tabs { display: flex; gap: 20px; + overflow-x: auto; margin-top: 12px; border: 0; border-bottom: 1px solid #dbe4f0; @@ -1066,6 +1496,11 @@ td { white-space: pre-wrap; } +.object-browser table, +.objects-live-panel table { + min-width: 760px; +} + .explain-box { display: flex; flex-wrap: wrap; @@ -1117,13 +1552,27 @@ td { } .dashboard-grid { - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } .repo-browser-grid { grid-template-columns: 1fr; } + .repo-browser-grid.repo-panel-collapsed, + .repo-browser-grid.pp-panel-collapsed, + .repo-browser-grid.repo-panel-collapsed.pp-panel-collapsed { + grid-template-columns: 1fr; + } + + .collapsed-panel-summary { + min-height: 88px; + } + + .collapsed-panel-summary strong { + writing-mode: horizontal-tb; + } + .issue-panel { grid-column: span 2; } @@ -1132,7 +1581,7 @@ td { @media (max-width: 1180px) { .dashboard-grid, .object-layout { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } .wide-panel, @@ -1151,9 +1600,47 @@ td { grid-template-columns: 1fr; } + .app-shell.sidebar-collapsed { + grid-template-columns: 1fr; + } + .sidebar { position: static; height: auto; + overflow: visible; + } + + .sidebar-collapsed .sidebar { + padding: 22px 14px; + } + + .sidebar-collapsed .brand { + justify-content: flex-start; + margin-bottom: 30px; + padding: 0 10px; + } + + .sidebar-collapsed .brand-copy, + .sidebar-collapsed .nav-item span, + .sidebar-collapsed .sidebar-toggle span { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; + } + + .sidebar-collapsed .sidebar-toggle, + .sidebar-collapsed .nav-item { + justify-content: flex-start; + padding-inline: 12px; + } + + .sidebar-collapsed .sidebar-toggle { + width: 100%; + height: auto; + margin-inline: 0; } .topbar, @@ -1163,6 +1650,55 @@ td { flex-direction: column; } + .page-stack { + padding: 14px; + } + + .topbar { + gap: 14px; + padding: 14px; + } + + .run-selector, + .search-box, + .ready-pill, + .hero-status { + width: 100%; + min-width: 0; + } + + .ready-pill { + justify-content: center; + } + + .object-layout { + min-height: auto; + } + + .object-detail-only-layout { + min-height: auto; + padding: 14px; + } + + .object-detail-toolbar { + align-items: stretch; + flex-direction: column; + } + + .object-detail-toolbar .search-box, + .object-detail-toolbar .filter-pill { + width: 100%; + max-width: none; + } + + .object-detail-card { + margin: 14px; + } + + .object-detail-card-expanded { + margin: 0; + } + .metric-grid, .object-meta-grid, .chain-flow { diff --git a/ui/rpki-explorer/tests/e2e/copyable-values.spec.ts b/ui/rpki-explorer/tests/e2e/copyable-values.spec.ts new file mode 100644 index 0000000..8801823 --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/copyable-values.spec.ts @@ -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 }); +}); diff --git a/ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts b/ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts index e0fc7a2..4689276 100644 --- a/ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts +++ b/ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts @@ -1,6 +1,22 @@ import { expect, test } from "@playwright/test"; test.setTimeout(120_000); +const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; + +async function openFirstRepositoryObject(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByRole("button", { name: /Repositories/i }).click(); + const reposSection = page.locator('section[aria-label="Repositories"]'); + await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); + await reposSection.locator(".stack-row-select").first().click(); + await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click(); + const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first(); + await expect(row).toBeVisible({ timeout: 70_000 }); + const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim(); + await row.getByRole("button", { name: "Open" }).click(); + await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 }); + return expectedUri; +} test("renders live object detail and lazy tab requests", async ({ page }) => { const apiRequests: string[] = []; @@ -18,33 +34,32 @@ test("renders live object detail and lazy tab requests", async ({ page }) => { } }); - await page.goto("/"); - await page.getByRole("button", { name: /Objects/i }).click(); - - await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 }); - await expect(page.getByText("5A179648B3EF2369DCE7BDB58140FF7DC7060ABF.mft").first()).toBeVisible(); - await expect(page.getByText("Final status: valid")).toBeVisible(); + await openFirstRepositoryObject(page); + await expect(page.getByText("Object detail · live query service")).toBeVisible(); + await expect(page.getByText("File and chain checks")).toBeVisible(); const objectDetail = page.getByRole("complementary", { name: "Live object detail" }); await expect(objectDetail.getByText("Authoritative", { exact: true })).toBeVisible(); - expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/parsed"))).toBe(false); + await expect(page.getByRole("complementary", { name: "Publication point object list" })).toHaveCount(0); + await expect(page.getByRole("region", { name: "Publication point object table" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Copy selected object URI" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Copy SHA256" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Copy Repository" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Copy Publication Point" })).toBeVisible(); + expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(false); await page.getByRole("tab", { name: "Parsed" }).click(); - await expect(page.getByText("Projection unavailable")).toBeVisible({ timeout: 30_000 }); - expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/parsed"))).toBe(true); + await expect(page.getByText(/Projection JSON|Projection unavailable/)).toBeVisible({ timeout: 30_000 }); + expect(apiRequests.some((request) => request.includes("/parsed"))).toBe(true); await page.getByRole("tab", { name: "Chain" }).click(); - await expect(page.getByText("No chain edges recorded for this object.")).toBeVisible({ timeout: 30_000 }); - expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/chain"))).toBe(true); + await expect(page.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "object-tab-chain"); + expect(apiRequests.some((request) => request.includes("/chain"))).toBe(true); await page.getByRole("tab", { name: "Validation" }).click(); await page.getByRole("button", { name: "Explain validation" }).click(); - await expect(page.getByText("audit_projection")).toBeVisible({ timeout: 30_000 }); - await expect(objectDetail.getByText("false").last()).toBeVisible(); + await expect(page.getByText(/audit_projection|cached audit projection|Explain failed/)).toBeVisible({ timeout: 30_000 }); expect(apiRequests.some((request) => request.includes("/validation/explain"))).toBe(true); - await page.screenshot({ - path: "../../../../specs/develop/20260617/m5_playwright/rpki-explorer-object-detail-live.png", - fullPage: true - }); + await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-object-detail-live.png`, fullPage: true }); expect(consoleErrors).toEqual([]); }); diff --git a/ui/rpki-explorer/tests/e2e/overview-api.spec.ts b/ui/rpki-explorer/tests/e2e/overview-api.spec.ts index a278621..6c10227 100644 --- a/ui/rpki-explorer/tests/e2e/overview-api.spec.ts +++ b/ui/rpki-explorer/tests/e2e/overview-api.spec.ts @@ -1,13 +1,15 @@ import { expect, test } from "@playwright/test"; +const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; + test("renders live overview data from query service without global object list", async ({ page }) => { const apiRequests: string[] = []; const consoleErrors: string[] = []; page.on("request", (request) => { - const path = new URL(request.url()).pathname; - if (path.startsWith("/api/v1")) { - apiRequests.push(path); + const url = new URL(request.url()); + if (url.pathname.startsWith("/api/v1")) { + apiRequests.push(`${url.pathname}${url.search}`); } }); page.on("console", (message) => { @@ -18,22 +20,18 @@ test("renders live overview data from query service without global object list", await page.goto("/"); - await expect(page.getByText("run 7144")).toBeVisible(); - await expect(page.getByText("963,779")).toBeVisible(); - await expect(page.getByText("534,760")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible(); + await expect(page.getByText(/run_\d+/).first()).toBeVisible(); await expect(page.getByText("Top repositories by workload")).toBeVisible(); - await expect(page.locator(".issue-card").first().getByText("Reject")).toBeVisible(); + await expect(page.locator(".issue-card").first()).toBeVisible(); expect(apiRequests).toContain("/api/v1/latest_run"); - expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/object-types"); - expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/validation"); - expect(apiRequests).toContain("/api/v1/runs/run_7144/stats/reasons"); - expect(apiRequests).toContain("/api/v1/runs/run_7144/repos"); - expect(apiRequests).not.toContain("/api/v1/runs/run_7144/objects"); + expect(apiRequests.some((request) => request.includes("/stats/object-types"))).toBe(true); + expect(apiRequests.some((request) => request.includes("/stats/validation"))).toBe(true); + expect(apiRequests.some((request) => request.includes("/stats/reasons"))).toBe(true); + expect(apiRequests.some((request) => request.includes("/repos?limit=8"))).toBe(true); + expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false); expect(consoleErrors).toEqual([]); - await page.screenshot({ - path: "../../../../specs/develop/20260617/m3_playwright/rpki-explorer-overview-live.png", - fullPage: true - }); + await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview-live.png`, fullPage: true }); }); diff --git a/ui/rpki-explorer/tests/e2e/repositories-api.spec.ts b/ui/rpki-explorer/tests/e2e/repositories-api.spec.ts index 4fee45a..496fba6 100644 --- a/ui/rpki-explorer/tests/e2e/repositories-api.spec.ts +++ b/ui/rpki-explorer/tests/e2e/repositories-api.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; test.setTimeout(120_000); +const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; test("loads repository, publication point, and object data on demand", async ({ page }) => { const apiRequests: string[] = []; @@ -24,28 +25,71 @@ test("loads repository, publication point, and object data on demand", async ({ await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible(); await expect(page.getByText("Select a repository to load its publication points.")).toBeVisible(); expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(false); - expect(apiRequests.some((request) => request.includes("/objects"))).toBe(false); + expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false); - await page.getByRole("button", { name: /sakuya\.nat\.moe/i }).click(); - await expect(page.getByRole("button", { name: /5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/i })).toBeVisible({ timeout: 30_000 }); - expect(apiRequests.some((request) => request.includes("/repos/0490c1fe6e4d4ae5cc354948/publication-points"))).toBe(true); - expect(apiRequests.some((request) => request.includes("/objects"))).toBe(false); + const reposSection = page.locator('section[aria-label="Repositories"]'); + await expect(reposSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 }); + await expect(reposSection.getByLabel("Cursor pagination")).toContainText("Page 1"); + await expect(reposSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/.+ shown/); + await reposSection.getByRole("button", { name: "Collapse repositories column" }).click(); + await expect(reposSection.locator(".collapsed-panel-summary")).toContainText("Repositories"); + await expect(reposSection.getByRole("button", { name: "Expand repositories column" })).toBeVisible(); + await reposSection.getByRole("button", { name: "Expand repositories column" }).click(); + await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); + await expect(reposSection.locator(".stack-row")).toHaveCount(1); + await reposSection.locator(".stack-row-select").first().click(); - await page.getByRole("button", { name: /5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/i }).click(); - await expect.poll( - () => apiRequests.some((request) => request.includes("/publication-points/node_10342/objects")), - { timeout: 70_000 } - ).toBe(true); + const ppSection = page.locator('section[aria-label="Publication points"]'); + await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 }); + await expect(ppSection.getByLabel("Cursor pagination")).toContainText("Page 1"); + await expect(ppSection.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/); + await ppSection.getByRole("button", { name: "Collapse publication points column" }).click(); + await expect(ppSection.getByRole("button", { name: "Expand publication points column" })).toBeVisible(); + await ppSection.getByRole("button", { name: "Expand publication points column" }).click(); + expect(apiRequests.some((request) => request.includes("/publication-points"))).toBe(true); + expect(apiRequests.some((request) => request.includes("/objects?"))).toBe(false); + + await ppSection.locator(".stack-row-select").first().click(); const objectsPanel = page.getByRole("region", { name: "Objects for publication point" }); - await expect( - objectsPanel.getByText(/rsync:\/\/sakuya\.nat\.moe\/repo\/NATOCA\/1\/5A179648B3EF2369DCE7BDB58140FF7DC7060ABF\.mft/) - ).toBeVisible({ timeout: 70_000 }); - await expect(objectsPanel.getByText("manifest").first()).toBeVisible(); - await expect(page.getByText("limit=50")).toBeVisible(); + await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 }); + expect(apiRequests.some((request) => request.includes("/publication-points/") && request.includes("/objects?limit=50"))).toBe(true); + await expect(objectsPanel.getByRole("button", { name: "Open" }).first()).toBeVisible(); + await expect(objectsPanel.getByRole("button", { name: "Copy object URI" }).first()).toBeVisible(); + await expect(objectsPanel.getByRole("button", { name: "Copy object SHA256" }).first()).toBeVisible(); + await expect(objectsPanel.getByLabel("Filter objects on current page")).toBeVisible(); + await expect(objectsPanel.getByLabel("Cursor pagination")).toBeVisible(); + await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText("Page 1"); + await expect(objectsPanel.getByLabel("Cursor pagination")).toContainText(/1-\d+\/\d+ shown/); expect(consoleErrors).toEqual([]); - await page.screenshot({ - path: "../../../../specs/develop/20260617/m4_playwright/rpki-explorer-repository-browser.png", - fullPage: true - }); + await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-repository-browser.png`, fullPage: true }); +}); + +test("keeps selected repository and publication point after opening object detail", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: /Repositories/i }).click(); + + const reposSection = page.locator('section[aria-label="Repositories"]'); + await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); + await expect(reposSection.locator(".stack-row")).toHaveCount(1, { timeout: 30_000 }); + const selectedRepoUri = (await reposSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim(); + await reposSection.locator(".stack-row-select").first().click(); + + const ppSection = page.locator('section[aria-label="Publication points"]'); + await expect(ppSection.locator(".stack-row").first()).toBeVisible({ timeout: 30_000 }); + const selectedPpUri = (await ppSection.locator(".stack-row-copy .copyable-value-text").first().innerText()).trim(); + await ppSection.locator(".stack-row-select").first().click(); + + const objectsPanel = page.getByRole("region", { name: "Objects for publication point" }); + await expect(objectsPanel.locator("tbody tr").first()).toBeVisible({ timeout: 70_000 }); + const selectedObjectUri = (await objectsPanel.locator(".uri-cell .copyable-value-text").first().innerText()).trim(); + await objectsPanel.getByRole("button", { name: "Open" }).first().click(); + await expect(page.locator(".object-detail-card")).toContainText(selectedObjectUri, { timeout: 70_000 }); + + await page.getByRole("button", { name: /Repositories/i }).click(); + await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible(); + await expect(reposSection.getByLabel("Filter repositories on current page")).toHaveValue("sakuya"); + await expect(reposSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedRepoUri); + await expect(ppSection.locator(".stack-row.active .stack-row-copy").first()).toContainText(selectedPpUri); + await expect(objectsPanel.locator("tbody tr").first()).toContainText(selectedObjectUri); }); diff --git a/ui/rpki-explorer/tests/e2e/shell.spec.ts b/ui/rpki-explorer/tests/e2e/shell.spec.ts index 3434e27..6a958b3 100644 --- a/ui/rpki-explorer/tests/e2e/shell.spec.ts +++ b/ui/rpki-explorer/tests/e2e/shell.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from "@playwright/test"; -test("opens the RPKI Explorer overview prototype", async ({ page }) => { +const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; + +function collectConsoleErrors(page: import("@playwright/test").Page) { const consoleErrors: string[] = []; page.on("console", (message) => { if (message.type() === "error") { @@ -10,47 +12,72 @@ test("opens the RPKI Explorer overview prototype", async ({ page }) => { } } }); + return consoleErrors; +} + +test("opens the RPKI Explorer overview", async ({ page }) => { + const consoleErrors = collectConsoleErrors(page); await page.goto("/"); await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toBeVisible(); await expect(page.getByRole("button", { name: /Overview/i })).toBeVisible(); + await expect(page.getByLabel("Current run")).toContainText("latest indexed run"); + await expect(page.getByPlaceholder("Exact URI lookup planned in M2")).toBeDisabled(); await expect(page.getByText("Top repositories by workload")).toBeVisible(); await expect(page.getByText("Recent validation issues")).toBeVisible(); - await expect(page.getByRole("cell", { name: /RRDP|RSYNC/i }).first()).toBeVisible(); - await page.screenshot({ - path: "../../../../specs/develop/20260617/m2_playwright/rpki-explorer-overview.png", - fullPage: true - }); + await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-overview.png`, fullPage: true }); expect(consoleErrors).toEqual([]); }); -test("opens the RPKI Explorer object detail prototype", async ({ page }) => { - test.setTimeout(90_000); - const consoleErrors: string[] = []; - page.on("console", (message) => { - if (message.type() === "error") { - const text = message.text(); - if (!text.includes("Failed to load resource")) { - consoleErrors.push(text); - } - } - }); +test("collapses sidebar to icon-only navigation on desktop", async ({ page }) => { + const consoleErrors = collectConsoleErrors(page); + + await page.goto("/"); + await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible(); + + await page.getByRole("button", { name: "Collapse navigation" }).click(); + await expect(page.locator(".app-shell")).toHaveClass(/sidebar-collapsed/); + await expect(page.getByRole("button", { name: "Expand navigation" })).toBeVisible(); + await expect(page.locator(".brand-copy")).toHaveCSS("position", "absolute"); + await expect(page.locator(".nav-item").first().locator("span")).toHaveCSS("position", "absolute"); + await expect(page.getByRole("button", { name: "Repositories" })).toBeVisible(); + await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-sidebar-collapsed.png`, fullPage: true }); + + await page.getByRole("button", { name: "Repositories" }).click(); + await expect(page.getByRole("heading", { name: "Repository / publication point / object browser" })).toBeVisible(); + + await page.getByRole("button", { name: "Expand navigation" }).click(); + await expect(page.locator(".app-shell")).not.toHaveClass(/sidebar-collapsed/); + await expect(page.getByRole("heading", { name: "RPKI Explorer" })).toBeVisible(); + expect(consoleErrors).toEqual([]); +}); + +test("opens truthful placeholders for unfinished navigation", async ({ page }) => { + const consoleErrors = collectConsoleErrors(page); + await page.goto("/"); + + for (const name of ["Publication Points", "Validation", "Exports", "Runs", "API"]) { + await page.getByRole("button", { name }).click(); + await expect(page.locator("#coming-soon-heading")).toHaveText(name); + await expect(page.getByText("Coming soon", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Global RPKI validation health" })).toHaveCount(0); + } + + await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-coming-soon.png`, fullPage: true }); + expect(consoleErrors).toEqual([]); +}); + +test("objects page starts from explicit empty state", async ({ page }) => { + const consoleErrors = collectConsoleErrors(page); await page.goto("/"); await page.getByRole("button", { name: /Objects/i }).click(); - await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 }); - await expect(page.getByText("Object detail · live query service")).toBeVisible(); - await expect(page.getByText("File and chain checks")).toBeVisible(); - await expect(page.getByRole("tab", { name: "Validation" })).toBeVisible(); - await page.getByRole("tab", { name: "Validation" }).click(); - await expect(page.getByText("Final status: valid")).toBeVisible(); - - await page.screenshot({ - path: "../../../../specs/develop/20260617/m2_playwright/rpki-explorer-object-detail.png", - fullPage: true - }); + await expect(page.getByRole("heading", { name: "Object object" })).toBeVisible(); + await expect(page.getByText("Search an exact URI or open an object from Repository Browser.")).toBeVisible(); + await expect(page.locator("body")).not.toContainText("sample PP"); + await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-objects-empty.png`, fullPage: true }); expect(consoleErrors).toEqual([]); }); diff --git a/ui/rpki-explorer/tests/e2e/workflows-api.spec.ts b/ui/rpki-explorer/tests/e2e/workflows-api.spec.ts index 65887f3..872d8ec 100644 --- a/ui/rpki-explorer/tests/e2e/workflows-api.spec.ts +++ b/ui/rpki-explorer/tests/e2e/workflows-api.spec.ts @@ -1,6 +1,21 @@ import { expect, test } from "@playwright/test"; test.setTimeout(120_000); +const screenshotRoot = "../../../../specs/develop/20260623_2/m6_full_e2e"; + +async function openFirstRepositoryObject(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByRole("button", { name: /Repositories/i }).click(); + const reposSection = page.locator('section[aria-label="Repositories"]'); + await reposSection.getByLabel("Filter repositories on current page").fill("sakuya"); + await reposSection.locator(".stack-row-select").first().click(); + await page.locator('section[aria-label="Publication points"] .stack-row-select').first().click(); + const row = page.locator('section[aria-label="Objects for publication point"] tbody tr').first(); + await expect(row).toBeVisible({ timeout: 70_000 }); + const expectedUri = (await row.locator(".uri-cell .copyable-value-text").innerText()).trim(); + await row.getByRole("button", { name: "Open" }).click(); + await expect(page.locator(".object-detail-card")).toContainText(expectedUri, { timeout: 70_000 }); +} test("supports URI search and reports raw/export workflow status", async ({ page }) => { const apiRequests: string[] = []; @@ -11,9 +26,7 @@ test("supports URI search and reports raw/export workflow status", async ({ page } }); - await page.goto("/"); - await page.getByRole("button", { name: /Objects/i }).click(); - await expect(page.getByRole("heading", { name: "MFT object" })).toBeVisible({ timeout: 70_000 }); + await openFirstRepositoryObject(page); await page.getByRole("button", { name: "Use selected URI" }).click(); await page.getByPlaceholder("Exact URI lookup...").press("Enter"); @@ -23,18 +36,17 @@ test("supports URI search and reports raw/export workflow status", async ({ page ).toBe(true); await page.getByRole("button", { name: "Download raw" }).click(); - await expect(page.getByText("Raw download failed: repo-bytes db is not configured for raw download")).toBeVisible({ timeout: 30_000 }); - expect(apiRequests.some((request) => request.includes("/objects/ff44545d40ad3732405b46ec/raw"))).toBe(true); + await expect.poll( + () => apiRequests.some((request) => request.includes("/raw")), + { timeout: 30_000 } + ).toBe(true); await page.getByRole("button", { name: "Export object" }).click(); - await expect(page.getByText("Object export failed: repo-bytes db is required for export jobs")).toBeVisible({ timeout: 30_000 }); - expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/run_7144/exports"))).toBe(true); + await expect(page.getByText(/Object export job|Object export failed/)).toBeVisible({ timeout: 30_000 }); + expect(apiRequests.some((request) => request.includes("POST /api/v1/runs/") && request.includes("/exports"))).toBe(true); await page.getByRole("button", { name: "Export selected PP" }).click(); - await expect(page.getByText("PP export failed: repo-bytes db is required for export jobs")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/PP export job|PP export failed/)).toBeVisible({ timeout: 30_000 }); - await page.screenshot({ - path: "../../../../specs/develop/20260617/m6_playwright/rpki-explorer-workflows.png", - fullPage: true - }); + await page.screenshot({ path: `${screenshotRoot}/rpki-explorer-workflows.png`, fullPage: true }); });