20260617 增加RPKI Explorer前端

This commit is contained in:
yuyr 2026-06-17 17:29:20 +08:00
parent 6ef2c98890
commit 56f0d10dc6
28 changed files with 7362 additions and 0 deletions

5
.gitignore vendored
View File

@ -2,3 +2,8 @@ target/
Cargo.lock Cargo.lock
perf.* perf.*
specs/* copy.excalidraw specs/* copy.excalidraw
ui/rpki-explorer/node_modules/
ui/rpki-explorer/dist/
ui/rpki-explorer/playwright-report/
ui/rpki-explorer/test-results/
ui/rpki-explorer/.vite/

View File

@ -0,0 +1,81 @@
# RPKI Explorer
RPKI Explorer is the SPA frontend for browsing ours RP `rpki_query_service` outputs.
## Scope
Current MVP covers:
- Overview dashboard from latest run, stats, validation reasons, and top repositories.
- Repository -> publication point -> object lazy browser.
- Object detail with live object record, validation summary, lazy parsed/chain/list tabs, and validation explain.
- Exact URI lookup, raw download action, and object / publication point export actions.
Known backend-dependent limits:
- Parsed projection, raw download, and export success require `rpki_query_service --repo-bytes-db <path>`.
- Without repo bytes, the UI shows explicit unavailable/error states instead of fabricating data.
- VRP IP/prefix lookup is not part of this MVP and is tracked separately.
- Query service does not provide CORS headers; use a same-origin proxy.
## Development
Start query service first:
```bash
rpki_2/rpki/target/llvm-cov-target/debug/rpki_query_service \
--query-db .scratch/rpki_explorer_m3_api/query-db \
--listen 127.0.0.1:19571
```
Start the Vite dev server with a proxy:
```bash
cd rpki_2/rpki/ui/rpki-explorer
npm install
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:19571 npm run dev -- --port 5173
```
The Vite dev server proxies `/api/v1/*` to `RPKI_EXPLORER_API_TARGET`; default target is `http://127.0.0.1:9557`.
## Production Build Preview
```bash
cd rpki_2/rpki/ui/rpki-explorer
npm run build
RPKI_EXPLORER_API_TARGET=http://127.0.0.1:19571 npm run preview -- --port 4173
```
For real deployment, serve `dist/` through a static server or reverse proxy and route `/api/v1/*` to `rpki_query_service` on the same origin.
## Validation
```bash
npm run typecheck
npm run lint
npm run build
npm audit --audit-level=high
npm run test:e2e
```
Playwright is intentionally configured with `workers: 1`. The current query service object endpoints use lazy `report.json` scans, so parallel browser tests can create artificial backend contention and false failures.
## Screenshot Evidence
Milestone screenshots are stored under:
```text
specs/develop/20260617/m*_playwright/
```
## Directory Layout
```text
src/api/ query-service client and TypeScript records
src/app/ app shell and top-level navigation state
src/components/layout/ sidebar/topbar shell
src/features/overview/ latest run overview dashboard
src/features/repositories repo -> PP -> object browser
src/features/object-detail live object detail and workflows
src/styles/globals.css MVP visual system
```

View File

@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
}
}
);

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RPKI Explorer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3945
ui/rpki-explorer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
{
"name": "rpki-explorer",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false",
"lint": "eslint .",
"preview": "vite preview --host 127.0.0.1",
"test:e2e": "playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@xyflow/react": "^12.8.2",
"lucide-react": "^0.468.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"recharts": "^3.8.1",
"zod": "^3.25.67"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@playwright/test": "^1.53.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^24.0.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^8.0.16",
"vitest": "^4.1.9"
}
}

View File

@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
outputDir: "./test-results",
fullyParallel: true,
workers: 1,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:5173",
trace: "retain-on-failure",
screenshot: "only-on-failure"
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] }
}
]
});

View File

@ -0,0 +1,101 @@
export interface ApiEnvelope<T> {
data: T;
page: { nextCursor: string | null; limit: number } | null;
meta: { runId: string | null; schemaVersion: number };
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message);
this.name = "ApiError";
}
}
export type JsonQuery = Record<string, string | number | boolean | null | undefined>;
export function withQuery(path: string, query?: JsonQuery): string {
if (!query) {
return path;
}
const search = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) {
continue;
}
search.set(key, String(value));
}
const queryString = search.toString();
return queryString ? `${path}?${queryString}` : path;
}
export async function getJson<T>(path: string, query?: JsonQuery): Promise<ApiEnvelope<T>> {
const response = await fetch(withQuery(path, query));
const body = (await response.json().catch(() => ({}))) as { error?: string };
if (!response.ok) {
throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status);
}
return normalizeEnvelope<T>(body);
}
export async function postJson<T>(path: string, body?: unknown): Promise<ApiEnvelope<T>> {
const response = await fetch(path, {
body: JSON.stringify(body ?? {}),
headers: { "content-type": "application/json" },
method: "POST"
});
const responseBody = (await response.json().catch(() => ({}))) as { error?: string };
if (!response.ok) {
throw new ApiError(responseBody.error ?? `Request failed: ${response.status}`, response.status);
}
return normalizeEnvelope<T>(responseBody);
}
export async function getBinary(path: string): Promise<Blob> {
const response = await fetch(path);
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { error?: string };
throw new ApiError(body.error ?? `Request failed: ${response.status}`, response.status);
}
return response.blob();
}
export function normalizeEnvelope<T>(value: unknown): ApiEnvelope<T> {
if (typeof value !== "object" || value === null) {
return {
data: value as T,
page: null,
meta: { runId: null, schemaVersion: 1 }
};
}
const record = value as {
data?: T;
page?: { nextCursor?: string | null; limit?: number } | null;
meta?: { runId?: string | null; schemaVersion?: number } | null;
};
return {
data: record.data as T,
page: record.page
? {
nextCursor: record.page.nextCursor ?? null,
limit: record.page.limit ?? 0
}
: null,
meta: {
runId: record.meta?.runId ?? null,
schemaVersion: record.meta?.schemaVersion ?? 1
}
};
}
export function normalizeApiError(error: unknown): Error {
if (error instanceof ApiError) {
return error;
}
if (error instanceof Error) {
return error;
}
return new Error("Unknown API error");
}

View File

@ -0,0 +1,301 @@
import { getBinary, getJson, postJson } from "./client";
export interface ServiceInfo {
service: string;
version: number;
}
export interface RunRecord {
schemaVersion: number;
runId: string;
runSeq: number | null;
runDir: string;
validationTime: string | null;
syncMode: string | null;
startedAt: string | null;
finishedAt: string | null;
wallMs: number | null;
artifactPaths: Record<string, string>;
counts: {
publicationPoints: number;
objects: number;
freshObjects: number;
cachedObjects: number;
rejectedObjects: number;
freshRejectedObjects: number;
cachedRejectedObjects: number;
trustAnchors: number;
vrps: number;
aspas: number;
warnings: number;
};
indexStatus: string;
indexError: string | null;
}
export interface RepositoryRecord {
schemaVersion: number;
runId: string;
repoId: string;
uri: string;
host: string;
transport: string;
publicationPoints: number;
objects: number;
rejectedObjects: number;
downloadBytes: number | null;
syncDurationMsTotal: number;
phases: Record<string, number>;
terminalStates: Record<string, number>;
}
export interface PublicationPointRecord {
schemaVersion: number;
runId: string;
ppId: string;
repoId: string;
nodeId: number | null;
parentNodeId: number | null;
rsyncBaseUri: string | null;
manifestRsyncUri: string | null;
publicationPointRsyncUri: string | null;
rrdpNotificationUri: string | null;
source: string | null;
repoSyncSource: string | null;
repoSyncPhase: string | null;
repoSyncDurationMs: number | null;
repoSyncError: string | null;
repoTerminalState: string | null;
thisUpdate: string | null;
nextUpdate: string | null;
verifiedAt: string | null;
objects: number;
rejectedObjects: number;
warnings: number;
}
export interface ObjectInstanceRecord {
schemaVersion: number;
runId: string;
objectInstanceId: string;
uri: string;
uriHash: string;
sha256: string;
objectType: string;
result: string;
detailSummary: string | null;
repoId: string;
ppId: string;
sourceSection: string;
rejected: boolean;
rejectReason: string | null;
}
export interface ObjectProjectionRecord {
schemaVersion?: number;
objectType?: string;
sha256?: string;
uri?: string;
parseStatus?: string;
errorSummary?: string | null;
projection?: unknown;
}
export interface ValidationIssueRecord {
stage?: string;
severity?: string;
reasonCode?: string;
summary?: string;
}
export interface ObjectValidationRecord {
objectInstanceId: string;
uri: string;
sha256: string;
objectType: string;
finalStatus: string;
auditResult: string;
detailSummary: string | null;
rejected: boolean;
rejectReason: string | null;
sourceSection: string;
explainAvailable: boolean;
authoritative: boolean;
fileValidation: {
status: string;
stage: string;
issues: ValidationIssueRecord[];
detailSummary: string | null;
};
chainValidation: {
status: string;
stage: string;
issues: ValidationIssueRecord[];
note?: string;
edgesPage?: { nextCursor: string | null; limit: number };
};
}
export interface ChainEdgeRecord {
relation: string;
fromUri: string;
toUri: string;
toObjectInstanceId: string | null;
toSha256: string | null;
status: string;
evidence: unknown;
}
export interface ValidationExplainRecord {
schemaVersion: number;
explainVersion: number;
runId: string;
objectInstanceId: string;
uri: string;
sha256: string;
objectType: string;
finalStatus: string;
auditResult: string;
detailSummary: string | null;
authoritative: boolean;
explainMode: string;
generatedAt: string;
parsevalidate: unknown;
chainvalidate: unknown;
chainEdges: ChainEdgeRecord[];
}
export interface ObjectUriIndexRecord {
runId: string;
uri: string;
sha256: string;
objectInstanceId: string;
repoId: string;
ppId: string;
}
export interface ExportJobRecord {
schemaVersion: number;
jobId: string;
runId: string;
scope: string;
repoId: string | null;
ppId: string | null;
status: string;
createdAt: string;
finishedAt: string | null;
outputPath: string | null;
objectCount: number;
bytesWritten: number;
error: string | null;
}
export type CountsByKey = Record<string, number>;
export async function getServiceInfo() {
return getJson<ServiceInfo>("/api/v1");
}
export async function getLatestRun() {
return getJson<RunRecord>("/api/v1/latest_run");
}
export async function getRunSummary(runId: string) {
return getJson<Record<string, number>>(`/api/v1/runs/${runId}/summary`);
}
export async function getStatsObjectTypes(runId: string) {
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/object-types`);
}
export async function getStatsValidation(runId: string) {
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/validation`);
}
export async function getStatsReasons(runId: string) {
return getJson<CountsByKey>(`/api/v1/runs/${runId}/stats/reasons`);
}
export async function getStatsDownloads(runId: string) {
return getJson<Record<string, unknown>>(`/api/v1/runs/${runId}/stats/downloads`);
}
export async function listRepos(runId: string, limit = 5, cursor?: string | null) {
return getJson<RepositoryRecord[]>(`/api/v1/runs/${runId}/repos`, { limit, cursor: cursor ?? undefined });
}
export async function listPublicationPointsForRepo(runId: string, repoId: string, limit = 50, cursor?: string | null) {
return getJson<PublicationPointRecord[]>(`/api/v1/runs/${runId}/repos/${repoId}/publication-points`, {
limit,
cursor: cursor ?? undefined
});
}
export async function listObjectsForPublicationPoint(runId: string, ppId: string, limit = 50, cursor?: string | null) {
return getJson<ObjectInstanceRecord[]>(`/api/v1/runs/${runId}/publication-points/${ppId}/objects`, {
limit,
cursor: cursor ?? undefined
});
}
export async function getObject(runId: string, objectInstanceId: string) {
return getJson<ObjectInstanceRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}`);
}
export async function getObjectByUri(runId: string, uri: string) {
return getJson<ObjectUriIndexRecord>(`/api/v1/runs/${runId}/objects/by-uri`, { uri });
}
export async function getObjectParsed(runId: string, objectInstanceId: string) {
return getJson<ObjectProjectionRecord | null>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed`);
}
export async function getObjectValidation(runId: string, objectInstanceId: string) {
return getJson<ObjectValidationRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/validation`);
}
export async function getObjectChain(runId: string, objectInstanceId: string) {
return getJson<ChainEdgeRecord[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/chain`);
}
export async function listManifestFiles(runId: string, objectInstanceId: string, limit = 50, cursor?: string | null) {
return getJson<unknown[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed/manifest-files`, {
limit,
cursor: cursor ?? undefined
});
}
export async function listRevokedCertificates(runId: string, objectInstanceId: string, limit = 50, cursor?: string | null) {
return getJson<unknown[]>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/parsed/revoked-certs`, {
limit,
cursor: cursor ?? undefined
});
}
export async function explainObjectValidation(runId: string, objectInstanceId: string, forceRefresh = false) {
return postJson<ValidationExplainRecord>(`/api/v1/runs/${runId}/objects/${objectInstanceId}/validation/explain`, {
forceRefresh
});
}
export async function downloadRawObject(runId: string, objectInstanceId: string) {
return getBinary(`/api/v1/runs/${runId}/objects/${objectInstanceId}/raw`);
}
export async function createObjectSetExport(runId: string, objectInstanceIds: string[]) {
return postJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports`, {
objectInstanceIds,
scope: "object_set"
});
}
export async function createPublicationPointExport(runId: string, ppId: string) {
return postJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports`, {
ppId,
scope: "publication_point"
});
}
export async function getExportJob(runId: string, jobId: string) {
return getJson<ExportJobRecord>(`/api/v1/runs/${runId}/exports/${jobId}`);
}

View File

@ -0,0 +1,35 @@
import { Activity, Braces, Database, FileSearch, GitBranch, Home, PackageOpen, ShieldCheck } 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";
const navigationItems = [
{ id: "overview", label: "Overview", icon: Home },
{ id: "repositories", label: "Repositories", icon: Database },
{ id: "publication-points", label: "Publication Points", icon: GitBranch },
{ id: "objects", label: "Objects", icon: Braces },
{ id: "validation", label: "Validation", icon: ShieldCheck },
{ id: "exports", label: "Exports", icon: PackageOpen },
{ id: "runs", label: "Runs", icon: Activity },
{ id: "api", label: "API", icon: FileSearch }
];
export function App() {
const [activeView, setActiveView] = useState("overview");
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(null);
const openObject = (objectInstanceId: string) => {
setSelectedObjectInstanceId(objectInstanceId);
setActiveView("objects");
};
return (
<Shell activeView={activeView} navigationItems={navigationItems} onNavigate={setActiveView}>
{activeView === "objects" ? <ObjectDetailPage initialObjectInstanceId={selectedObjectInstanceId} /> : null}
{activeView === "repositories" ? <RepositoriesPage onOpenObject={openObject} /> : null}
{activeView !== "objects" && activeView !== "repositories" ? <OverviewPage /> : null}
</Shell>
);
}

View File

@ -0,0 +1,7 @@
import { QueryClientProvider } from "@tanstack/react-query";
import type { PropsWithChildren } from "react";
import { queryClient } from "./queryClient";
export function AppProviders({ children }: PropsWithChildren) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@ -0,0 +1,15 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
gcTime: 10 * 60_000,
retry: 1,
refetchOnWindowFocus: false
},
mutations: {
retry: false
}
}
});

View File

@ -0,0 +1,66 @@
import type { LucideIcon } from "lucide-react";
import { BarChart3, ChevronDown, CircleHelp, Search, Sun } from "lucide-react";
import type { PropsWithChildren } from "react";
export interface NavigationItem {
id: string;
label: string;
icon: LucideIcon;
}
interface ShellProps extends PropsWithChildren {
activeView: string;
navigationItems: NavigationItem[];
onNavigate: (id: string) => void;
}
export function Shell({ activeView, navigationItems, onNavigate, children }: ShellProps) {
return (
<div className="app-shell">
<aside className="sidebar" aria-label="Primary navigation">
<div className="brand">
<div className="brand-mark">R</div>
<div>
<h1 className="brand-title">RPKI Explorer</h1>
<div className="brand-subtitle">Validation Intelligence</div>
</div>
</div>
<nav className="nav-list">
{navigationItems.map((item) => (
<button
aria-current={activeView === item.id ? "page" : undefined}
className={`nav-item ${activeView === item.id ? "active" : ""}`}
key={item.id}
onClick={() => onNavigate(item.id)}
type="button"
>
<item.icon aria-hidden="true" size={18} />
<span>{item.label}</span>
</button>
))}
</nav>
</aside>
<main className="main-panel">
<header className="topbar">
<div className="run-selector" aria-label="Current run">
<span>Run</span>
<strong>latest run</strong>
<ChevronDown aria-hidden="true" size={16} />
</div>
<div className="topbar-actions">
<label className="search-box">
<Search aria-hidden="true" size={18} />
<span className="sr-only">Search URI or hash</span>
<input placeholder="Search URI / hash / ASN / prefix…" />
</label>
<div className="ready-pill"><span /> Ready</div>
<button aria-label="Help" className="icon-button" type="button"><CircleHelp size={19} /></button>
<button aria-label="Theme" className="icon-button" type="button"><Sun size={19} /></button>
<button aria-label="Analytics" className="icon-button" type="button"><BarChart3 size={19} /></button>
</div>
</header>
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,530 @@
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 {
createObjectSetExport,
createPublicationPointExport,
downloadRawObject,
explainObjectValidation,
getLatestRun,
getObject,
getObjectByUri,
getObjectChain,
getObjectParsed,
getObjectValidation,
listManifestFiles,
listObjectsForPublicationPoint,
listRevokedCertificates,
type ChainEdgeRecord,
type ObjectInstanceRecord,
type ObjectValidationRecord,
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];
interface ObjectDetailPageProps {
initialObjectInstanceId?: string | null;
}
export function ObjectDetailPage({ initialObjectInstanceId }: ObjectDetailPageProps) {
const [selectedObjectInstanceId, setSelectedObjectInstanceId] = useState<string | null>(initialObjectInstanceId ?? null);
const [activeTab, setActiveTab] = useState<ObjectTab>("Validation");
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);
}
}, [objectRows, selectedObjectInstanceId]);
const objectQuery = useQuery({
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
queryKey: ["object-detail-object", runId, selectedObjectInstanceId],
queryFn: () => getObject(runId, selectedObjectInstanceId ?? ""),
staleTime: 5 * 60 * 1000
});
const validationQuery = useQuery({
enabled: Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
queryKey: ["object-detail-validation", runId, selectedObjectInstanceId],
queryFn: () => getObjectValidation(runId, selectedObjectInstanceId ?? ""),
staleTime: 5 * 60 * 1000
});
const parsedQuery = useQuery({
enabled: activeTab === "Parsed" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
queryKey: ["object-detail-parsed", runId, selectedObjectInstanceId],
queryFn: () => getObjectParsed(runId, selectedObjectInstanceId ?? ""),
staleTime: 5 * 60 * 1000
});
const chainQuery = useQuery({
enabled: activeTab === "Chain" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
queryKey: ["object-detail-chain", runId, selectedObjectInstanceId],
queryFn: () => getObjectChain(runId, selectedObjectInstanceId ?? ""),
staleTime: 5 * 60 * 1000
});
const manifestFilesQuery = useQuery({
enabled: activeTab === "Manifest files" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
retry: false,
queryKey: ["object-detail-manifest-files", runId, selectedObjectInstanceId],
queryFn: () => listManifestFiles(runId, selectedObjectInstanceId ?? "", 50),
staleTime: 5 * 60 * 1000
});
const revokedCertsQuery = useQuery({
enabled: activeTab === "Revoked certs" && Boolean(selectedObjectInstanceId && latestRunQuery.data?.data.runId),
retry: false,
queryKey: ["object-detail-revoked-certs", runId, selectedObjectInstanceId],
queryFn: () => listRevokedCertificates(runId, selectedObjectInstanceId ?? "", 50),
staleTime: 5 * 60 * 1000
});
const explainMutation = useMutation({
mutationFn: () => explainObjectValidation(runId, selectedObjectInstanceId ?? "")
});
const uriSearchMutation = useMutation({
mutationFn: (uri: string) => getObjectByUri(runId, uri),
onSuccess: (result) => {
setSelectedObjectInstanceId(result.data.objectInstanceId);
setActiveTab("Validation");
}
});
const rawDownloadMutation = useMutation({
mutationFn: () => downloadRawObject(runId, selectedObjectInstanceId ?? "")
});
const objectExportMutation = useMutation({
mutationFn: () => createObjectSetExport(runId, selectedObjectInstanceId ? [selectedObjectInstanceId] : [])
});
const ppExportMutation = useMutation({
mutationFn: () => createPublicationPointExport(runId, selectedObject?.ppId ?? SAMPLE_PP_ID)
});
const selectedObject = objectQuery.data?.data ?? objectRows.find((item) => item.objectInstanceId === selectedObjectInstanceId) ?? null;
const validation = validationQuery.data?.data ?? null;
const apiError = latestRunQuery.error ?? objectsQuery.error ?? objectQuery.error ?? validationQuery.error;
return (
<section className="page-stack object-page" aria-labelledby="object-detail-heading">
{apiError ? <ErrorBanner error={apiError} /> : null}
<div className="object-layout live-object-layout">
<aside className="repository-tree" aria-label="Publication point object list">
<p className="eyebrow">Live object list</p>
<h3>sakuya.nat.moe sample PP</h3>
<div className="tree-node root">{latestRunQuery.data?.data.runId ?? "Loading run"}</div>
{objectsQuery.isFetching ? <LoadingLine label="Loading PP objects..." /> : null}
<div className="object-picker-list">
{objectOptions.map(({ object, label }) => (
<button
className={`tree-node nested object-picker ${object.objectInstanceId === selectedObjectInstanceId ? "current" : ""}`}
key={object.objectInstanceId}
onClick={() => {
setSelectedObjectInstanceId(object.objectInstanceId);
setActiveTab("Validation");
explainMutation.reset();
}}
type="button"
>
<strong>{object.objectType}</strong>
<span>{label}</span>
</button>
))}
</div>
</aside>
<div className="object-main">
<section className="object-browser" aria-label="Publication point object table">
<div className="browser-toolbar">
<form
className="search-box object-uri-search"
onSubmit={(event) => {
event.preventDefault();
if (searchUri.trim()) {
uriSearchMutation.mutate(searchUri.trim());
}
}}
>
<Search aria-hidden="true" size={17} />
<span className="sr-only">Search exact object URI</span>
<input
onChange={(event) => setSearchUri(event.target.value)}
placeholder="Exact URI lookup..."
value={searchUri}
/>
</form>
<button className="filter-pill" type="button" onClick={() => selectedObject && setSearchUri(selectedObject.uri)}>Use selected URI</button>
<strong>{objectRows.length} objects</strong>
</div>
<table>
<thead>
<tr>
<th>Type</th>
<th>URI</th>
<th>Hash (SHA-256)</th>
<th>Source</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{objectRows.map((object) => (
<tr
className={object.objectInstanceId === selectedObjectInstanceId ? "object-row-active" : ""}
key={object.objectInstanceId}
onClick={() => setSelectedObjectInstanceId(object.objectInstanceId)}
>
<td><span className="object-type">{labelObjectType(object.objectType)}</span></td>
<td className="uri-cell">{object.uri}</td>
<td>{object.sha256.slice(0, 16)}...</td>
<td>{object.sourceSection}</td>
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
</tr>
))}
</tbody>
</table>
</section>
<aside className="object-detail-card" aria-label="Live object detail">
<div className="object-hero">
<div>
<p className="eyebrow">Object detail · live query service</p>
<h2 id="object-detail-heading">{selectedObject ? labelObjectType(selectedObject.objectType) : "Object"} object</h2>
<p>{selectedObject?.uri ?? "Select an object from the publication point list."}</p>
</div>
<div className="object-actions">
<StatusPill state={validation?.finalStatus ?? selectedObject?.result ?? "loading"} />
<button className="ghost-button" type="button">
<Copy size={16} /> Copy URI
</button>
<button className="ghost-button" onClick={() => objectExportMutation.mutate()} type="button">
<FileText size={16} /> Export object
</button>
<button className="primary-button" onClick={() => rawDownloadMutation.mutate()} type="button">
<Download size={16} /> Download raw
</button>
</div>
</div>
{selectedObject ? <ObjectMeta object={selectedObject} validation={validation} /> : <div className="empty-state">No object selected.</div>}
<WorkflowStatus
objectExport={objectExportMutation.data?.data ?? null}
objectExportError={objectExportMutation.error}
ppExport={ppExportMutation.data?.data ?? null}
ppExportError={ppExportMutation.error}
rawError={rawDownloadMutation.error}
rawPending={rawDownloadMutation.isPending}
searchError={uriSearchMutation.error}
searchPending={uriSearchMutation.isPending}
/>
<div className="object-actions inline-actions">
<button className="ghost-button" onClick={() => ppExportMutation.mutate()} type="button">
<FileText size={16} /> Export selected PP
</button>
</div>
<div className="tabs" role="tablist" aria-label="Object detail tabs">
{tabs.map((tab) => (
<button
aria-selected={activeTab === tab}
className={activeTab === tab ? "active" : ""}
key={tab}
onClick={() => setActiveTab(tab)}
role="tab"
type="button"
>
{tab}
</button>
))}
</div>
<div className="object-content-grid">
{activeTab === "Parsed" ? <ParsedPanel parsed={parsedQuery.data?.data ?? null} isFetching={parsedQuery.isFetching} error={parsedQuery.error} /> : null}
{activeTab === "Validation" ? (
<ValidationPanel validation={validation} isFetching={validationQuery.isFetching} explain={explainMutation.data?.data ?? null} explainPending={explainMutation.isPending} onExplain={() => explainMutation.mutate()} explainError={explainMutation.error} />
) : null}
{activeTab === "Chain" ? <ChainPanel edges={chainQuery.data?.data ?? []} isFetching={chainQuery.isFetching} error={chainQuery.error} /> : null}
{activeTab === "Manifest files" ? <ArrayPanel title="Manifest files" rows={manifestFilesQuery.data?.data ?? []} isFetching={manifestFilesQuery.isFetching} error={manifestFilesQuery.error} /> : null}
{activeTab === "Revoked certs" ? <ArrayPanel title="Revoked certificates" rows={revokedCertsQuery.data?.data ?? []} isFetching={revokedCertsQuery.isFetching} error={revokedCertsQuery.error} /> : null}
</div>
</aside>
</div>
</div>
</section>
);
}
function ObjectMeta({ object, validation }: { object: ObjectInstanceRecord; validation: ObjectValidationRecord | null }) {
return (
<div className="object-meta-grid">
<Meta label="SHA256" value={object.sha256} />
<Meta label="Source" value={object.sourceSection} />
<Meta label="Repository" value={object.repoId} />
<Meta label="Publication Point" value={object.ppId} />
<Meta label="Audit Result" value={validation?.auditResult ?? object.result} />
<Meta label="Authoritative" value={validation ? String(validation.authoritative) : "loading"} />
</div>
);
}
function WorkflowStatus({
objectExport,
objectExportError,
ppExport,
ppExportError,
rawError,
rawPending,
searchError,
searchPending
}: {
objectExport: { jobId?: string; status?: string } | null;
objectExportError: unknown;
ppExport: { jobId?: string; status?: string } | null;
ppExportError: unknown;
rawError: unknown;
rawPending: boolean;
searchError: unknown;
searchPending: boolean;
}) {
const lines = [
searchPending ? "Searching object URI..." : null,
searchError ? `URI search failed: ${normalizeApiError(searchError).message}` : null,
rawPending ? "Downloading raw object..." : null,
rawError ? `Raw download failed: ${normalizeApiError(rawError).message}` : null,
objectExport ? `Object export job ${objectExport.jobId ?? ""} ${objectExport.status ?? ""}`.trim() : null,
objectExportError ? `Object export failed: ${normalizeApiError(objectExportError).message}` : null,
ppExport ? `PP export job ${ppExport.jobId ?? ""} ${ppExport.status ?? ""}`.trim() : null,
ppExportError ? `PP export failed: ${normalizeApiError(ppExportError).message}` : null
].filter((line): line is string => Boolean(line));
if (lines.length === 0) {
return null;
}
return (
<section className="panel workflow-status">
<PanelHeading eyebrow="Workflow" title="Search / raw / export status" />
<ul className="workflow-status-list">
{lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</section>
);
}
function ParsedPanel({ parsed, isFetching, error }: { parsed: unknown; isFetching: boolean; error: unknown }) {
if (isFetching) {
return <LoadingPanel title="Parsed projection" label="Loading parsed projection..." />;
}
if (error) {
return <NoticePanel title="Parsed projection" message={normalizeApiError(error).message} />;
}
if (!parsed) {
return <NoticePanel title="Parsed projection" message="Projection unavailable. The local query service was started without repo-bytes.db, so this object cannot be decoded on demand." />;
}
return (
<section className="panel parsed-panel">
<PanelHeading eyebrow="Parsed projection" title="Projection JSON" icon={<FileText className="panel-icon" size={22} />} />
<JsonPreview value={parsed} />
</section>
);
}
function ValidationPanel({ validation, isFetching, explain, explainPending, onExplain, explainError }: {
validation: ObjectValidationRecord | null;
isFetching: boolean;
explain: ValidationExplainRecord | null;
explainPending: boolean;
onExplain: () => void;
explainError: unknown;
}) {
if (isFetching) {
return <LoadingPanel title="Validation" label="Loading validation summary..." />;
}
return (
<section className="panel validation-panel">
<PanelHeading eyebrow="Validation" title="File and chain checks" icon={<ShieldCheck className="panel-icon success" size={22} />} />
<div className="check-list">
<ValidationCheck label="Final status" status={validation?.finalStatus ?? "unknown"} note={validation?.detailSummary ?? "Derived from run audit state."} />
<ValidationCheck label="File validation" status={validation?.fileValidation.status ?? "unknown"} note={issuesText(validation?.fileValidation.issues) ?? validation?.fileValidation.detailSummary ?? "No file issues recorded."} />
<ValidationCheck label="Chain validation" status={validation?.chainValidation.status ?? "unknown"} note={issuesText(validation?.chainValidation.issues) ?? validation?.chainValidation.note ?? "No chain issues recorded."} />
</div>
<div className="explain-box">
<button className="ghost-button" disabled={explainPending} onClick={onExplain} type="button">
{explainPending ? <Loader2 size={16} /> : <ShieldCheck size={16} />}
Explain validation
</button>
<span>Explain is an audit projection, not full authoritative revalidation.</span>
</div>
{explainError ? <div className="empty-state">Explain failed: {normalizeApiError(explainError).message}</div> : null}
{explain ? (
<div className="detail-list explain-result">
<Meta label="Mode" value={explain.explainMode} />
<Meta label="Authoritative" value={String(explain.authoritative)} />
<Meta label="Generated" value={explain.generatedAt} />
<Meta label="Edges" value={String(explain.chainEdges.length)} />
</div>
) : null}
</section>
);
}
function ChainPanel({ edges, isFetching, error }: { edges: ChainEdgeRecord[]; isFetching: boolean; error: unknown }) {
if (isFetching) {
return <LoadingPanel title="Certificate chain" label="Loading chain edges..." />;
}
if (error) {
return <NoticePanel title="Certificate chain" message={normalizeApiError(error).message} />;
}
return (
<section className="panel chain-panel">
<PanelHeading eyebrow="Certificate chain" title="Audit edge graph" icon={<GitBranch className="panel-icon" size={22} />} />
{edges.length === 0 ? <div className="empty-state">No chain edges recorded for this object.</div> : null}
<div className="chain-flow live-chain-flow">
{edges.map((edge) => (
<div className="chain-node" key={`${edge.relation}-${edge.toUri}`}>
<strong>{edge.relation}</strong>
<span>{edge.status}</span>
<code>{edge.toUri}</code>
</div>
))}
</div>
</section>
);
}
function ArrayPanel({ title, rows, isFetching, error }: { title: string; rows: unknown[]; isFetching: boolean; error: unknown }) {
if (isFetching) {
return <LoadingPanel title={title} label={`Loading ${title.toLowerCase()}...`} />;
}
if (error) {
return <NoticePanel title={title} message={normalizeApiError(error).message} />;
}
return (
<section className="panel table-panel">
<PanelHeading eyebrow={title} title="Paged projection list" />
{rows.length === 0 ? <div className="empty-state">No rows returned for this object.</div> : null}
{rows.length > 0 ? <JsonPreview value={rows} /> : null}
</section>
);
}
function LoadingPanel({ title, label }: { title: string; label: string }) {
return (
<section className="panel">
<PanelHeading eyebrow={title} title={title} />
<LoadingLine label={label} />
</section>
);
}
function NoticePanel({ title, message }: { title: string; message: string }) {
return (
<section className="panel">
<PanelHeading eyebrow={title} title={title} />
<div className="empty-state">{message}</div>
</section>
);
}
function PanelHeading({ eyebrow, title, icon }: { eyebrow: string; title: string; icon?: React.ReactNode }) {
return (
<div className="panel-heading">
<div>
<p className="eyebrow">{eyebrow}</p>
<h3>{title}</h3>
</div>
{icon}
</div>
);
}
function ValidationCheck({ label, status, note }: { label: string; status: string; note: string }) {
const ok = status === "valid" || status === "ok";
return (
<article className={`check-item ${ok ? "" : "warning-check"}`}>
<Check size={16} />
<div>
<strong>{label}: {status}</strong>
<p>{note}</p>
</div>
</article>
);
}
function JsonPreview({ value }: { value: unknown }) {
const text = useMemo(() => JSON.stringify(value, null, 2), [value]);
return <pre className="json-preview">{text}</pre>;
}
function ErrorBanner({ error }: { error: unknown }) {
return (
<div className="alert-banner" role="status">
<strong>Object API unavailable</strong>
<span>{normalizeApiError(error).message}</span>
</div>
);
}
function StatusPill({ state }: { state: string }) {
const normalized = state.toLowerCase();
const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error") || normalized.includes("invalid")
? "warning"
: normalized.includes("cache") || normalized.includes("fallback")
? "fallback"
: "healthy";
return <span className={`status-badge ${className}`}>{state}</span>;
}
function LoadingLine({ label }: { label: string }) {
return (
<div className="loading-line">
<Loader2 size={16} />
{label}
</div>
);
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div className="meta-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function labelObjectType(type: string) {
const labels: Record<string, string> = {
certificate: "CER",
crl: "CRL",
manifest: "MFT",
roa: "ROA",
aspa: "ASPA"
};
return labels[type] ?? type.toUpperCase();
}
function shortName(uri: string) {
return uri.split("/").filter(Boolean).at(-1) ?? uri;
}
function issuesText(issues: { summary?: string }[] | undefined) {
if (!issues || issues.length === 0) {
return null;
}
return issues.map((issue) => issue.summary).filter(Boolean).join("; ");
}

View File

@ -0,0 +1,347 @@
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, ArrowUpRight, CheckCircle2, Clock3, ShieldCheck } from "lucide-react";
import { objectTypeRows, overviewKpis, topRepositories, validationIssues, validationSlices } from "../../test/fixtures/dashboard";
import {
getLatestRun,
getStatsObjectTypes,
getStatsReasons,
getStatsValidation,
listRepos,
type CountsByKey,
type RepositoryRecord,
type RunRecord
} from "../../api/queryService";
import { normalizeApiError } from "../../api/client";
const validationColors = ["#0ca678", "#facc15", "#e11d48", "#94a3b8"];
const objectTypeLabels: Record<string, string> = {
aspa: "ASPA",
certificate: "CER / RC",
crl: "CRL",
gbr: "GBR",
manifest: "MFT",
roa: "ROA",
router_certificate: "Router Cert"
};
export function OverviewPage() {
const latestRunQuery = useQuery({
queryKey: ["latest-run"],
queryFn: getLatestRun
});
const liveRun = latestRunQuery.data?.data;
const runId = liveRun?.runId ?? "latest";
const enabled = Boolean(liveRun);
const objectTypesQuery = useQuery({
queryKey: ["stats-object-types", runId],
queryFn: () => getStatsObjectTypes(runId),
enabled
});
const validationQuery = useQuery({
queryKey: ["stats-validation", runId],
queryFn: () => getStatsValidation(runId),
enabled
});
const reasonsQuery = useQuery({
queryKey: ["stats-reasons", runId],
queryFn: () => getStatsReasons(runId),
enabled
});
const reposQuery = useQuery({
queryKey: ["top-repos", runId],
queryFn: () => listRepos(runId, 8),
enabled
});
const error = latestRunQuery.error ? normalizeApiError(latestRunQuery.error) : null;
const kpis = liveRun ? kpisFromRun(liveRun) : overviewKpis;
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 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";
return (
<section className="page-stack" aria-labelledby="overview-heading">
{error ? (
<div className="alert-banner" role="status">
<strong>Query service unavailable</strong>
<span>{error.message}. Showing static fallback data for layout inspection.</span>
</div>
) : null}
<div className="hero-dashboard">
<div>
<p className="eyebrow">{liveRun ? `Latest ready run · ${liveRun.syncMode ?? "unknown"} mode` : "Latest ready run · fallback"}</p>
<h2 id="overview-heading">Global RPKI validation health</h2>
<p>
{liveRun
? `Live query-service view for ${liveRun.runId}, validated at ${formatTimestamp(liveRun.validationTime)}.`
: "Static fallback showing how operators inspect validation output, repository health, and input quality."}
</p>
</div>
<div className="hero-status">
<ShieldCheck size={20} />
<div>
<strong>{statusText}</strong>
<span>{statusSubtext}</span>
</div>
</div>
</div>
<div className="metric-grid" aria-label="Overview KPI cards">
{kpis.map((metric) => (
<article className={`metric-card tone-${metric.tone}`} key={metric.label}>
<span>{metric.label}</span>
<strong>{metric.value}</strong>
<small>{metric.delta}</small>
</article>
))}
</div>
<div className="dashboard-grid">
<section className="panel validation-card">
<div className="panel-heading">
<div>
<p className="eyebrow">Validation</p>
<h3>Result distribution</h3>
</div>
<CheckCircle2 className="panel-icon success" size={22} />
</div>
<div className="donut-wrap">
<div className="donut" aria-label="Validation distribution donut" style={{ background: donutGradient(validationRows) }} />
<div className="donut-center">
<strong>{validPercent}%</strong>
<span>usable</span>
</div>
</div>
<div className="legend-list">
{validationRows.map((slice) => (
<div className="legend-row" key={slice.label}>
<span style={{ backgroundColor: slice.color }} />
<label>{slice.label}</label>
<strong>{slice.value}%</strong>
</div>
))}
</div>
</section>
<section className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Objects</p>
<h3>Object type mix</h3>
</div>
<Clock3 className="panel-icon" size={22} />
</div>
<div className="bar-list">
{objectRows.map((row) => (
<div className="bar-row" key={row.type}>
<div className="bar-meta">
<strong>{row.type}</strong>
<span>{row.count}</span>
</div>
<div className="bar-track">
<span style={{ width: `${row.percent}%` }} />
</div>
</div>
))}
</div>
</section>
<section className="panel table-panel wide-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Repositories</p>
<h3>Top repositories by workload</h3>
</div>
<button className="ghost-button" type="button">
View all <ArrowUpRight size={16} />
</button>
</div>
<table>
<thead>
<tr>
<th>Host</th>
<th>Transport</th>
<th>Duration</th>
<th>Objects</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{repoRows.map((repo) => (
<tr key={repo.host}>
<td>{repo.host}</td>
<td>{repo.transport}</td>
<td>{repo.duration}</td>
<td>{repo.objects}</td>
<td>
<span className={`status-badge ${repo.status}`}>{repo.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="panel issue-panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Triage</p>
<h3>Recent validation issues</h3>
</div>
<AlertTriangle className="panel-icon warning" size={22} />
</div>
<div className="issue-list">
{issueRows.map((issue) => (
<article className="issue-card" key={issue.uri}>
<div>
<span className={`severity ${issue.severity}`}>{issue.severity}</span>
<strong>{issue.type}</strong>
</div>
<p>{issue.reason}</p>
<code>{issue.uri}</code>
<small>{issue.repo}</small>
</article>
))}
</div>
</section>
</div>
</section>
);
}
function kpisFromRun(run: RunRecord) {
return [
{ label: "VRPs", value: formatNumber(run.counts.vrps), delta: "latest run", tone: "blue" },
{ label: "ASPAs", value: formatNumber(run.counts.aspas), delta: "latest run", tone: "cyan" },
{ label: "Objects", value: formatNumber(run.counts.objects), delta: "indexed inputs", tone: "blue" },
{ label: "Publication Points", value: formatNumber(run.counts.publicationPoints), delta: "indexed", tone: "purple" },
{ label: "Rejected Objects", value: formatNumber(run.counts.rejectedObjects), delta: "validation rejects", tone: "red" },
{ label: "Warnings", value: formatNumber(run.counts.warnings), delta: `${formatDuration(run.wallMs)} wall`, tone: "amber" }
];
}
function objectRowsFromStats(stats: CountsByKey) {
const total = sumValues(stats) || 1;
return Object.entries(stats)
.sort(([, left], [, right]) => right - left)
.slice(0, 7)
.map(([type, count]) => ({
type: objectTypeLabels[type] ?? type,
count: formatNumber(count),
percent: Math.max(1, Math.round((count / total) * 100))
}));
}
function validationRowsFromStats(stats: CountsByKey) {
const total = sumValues(stats) || 1;
return Object.entries(stats)
.sort(([, left], [, right]) => right - left)
.map(([label, count], index) => ({
label: titleCase(label),
value: Math.round((count / total) * 100),
color: validationColors[index] ?? "#94a3b8"
}));
}
function reposFromApi(repos: RepositoryRecord[]) {
return [...repos]
.sort((left, right) => right.objects - left.objects)
.slice(0, 6)
.map((repo) => ({
host: repo.host,
transport: repo.transport.toUpperCase(),
duration: formatDuration(repo.syncDurationMsTotal),
objects: formatNumber(repo.objects),
status: repoStatus(repo)
}));
}
function issuesFromReasons(reasons: CountsByKey) {
const entries = Object.entries(reasons).sort(([, left], [, right]) => right - left);
if (entries.length === 0) {
return [
{
severity: "low",
type: "OK",
reason: "No validation reject reasons reported in the latest run.",
uri: "query-service://validation/reasons",
repo: "latest run"
}
];
}
return entries.slice(0, 4).map(([reason, count], index) => ({
severity: index === 0 ? "high" : index === 1 ? "medium" : "low",
type: "Reject",
reason: `${formatNumber(count)} object(s): ${reason}`,
uri: `query-service://validation/reasons/${index + 1}`,
repo: "latest run"
}));
}
function repoStatus(repo: RepositoryRecord) {
if (repo.terminalStates.failed_no_cache || repo.phases.rrdp_failed_rsync_failed) {
return "warning";
}
if (repo.terminalStates.fallback_current_instance || repo.phases.rsync_ok) {
return "fallback";
}
return "healthy";
}
function statusForRun(run: RunRecord) {
if (run.counts.rejectedObjects > 0 || run.counts.warnings > 0) {
return "Healthy with warnings";
}
return "Healthy";
}
function donutGradient(rows: Array<{ value: number; color: string }>) {
let start = 0;
const parts = rows.map((row) => {
const end = Math.min(100, start + row.value);
const segment = `${row.color} ${start}% ${end}%`;
start = end;
return segment;
});
return `conic-gradient(${parts.join(", ")})`;
}
function sumValues(values?: CountsByKey) {
return Object.values(values ?? {}).reduce((sum, value) => sum + value, 0);
}
function percent(value: number, total: number) {
if (total <= 0) {
return 0;
}
return Math.round((value / total) * 100);
}
function formatNumber(value: number) {
return new Intl.NumberFormat("en-US").format(value);
}
function formatDuration(ms: number | null) {
if (ms === null || ms === undefined) {
return "—";
}
if (ms < 1000) {
return `${ms}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
}
function formatTimestamp(value: string | null) {
if (!value) {
return "unknown time";
}
return value.replace("T", " ").replace("Z", " UTC");
}
function titleCase(value: string) {
return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}

View File

@ -0,0 +1,224 @@
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 {
getLatestRun,
listObjectsForPublicationPoint,
listPublicationPointsForRepo,
listRepos,
type ObjectInstanceRecord
} from "../../api/queryService";
import { normalizeApiError } from "../../api/client";
interface RepositoriesPageProps {
onOpenObject?: (objectInstanceId: string) => void;
}
export function RepositoriesPage({ onOpenObject }: RepositoriesPageProps) {
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
const [selectedPpId, setSelectedPpId] = useState<string | null>(null);
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),
enabled: Boolean(latestRunQuery.data?.data.runId)
});
const repos = reposQuery.data?.data ?? [];
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),
enabled: Boolean(selectedRepo?.repoId)
});
const publicationPoints = ppsQuery.data?.data ?? [];
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),
enabled: Boolean(selectedPp?.ppId)
});
const error = latestRunQuery.error ?? reposQuery.error ?? ppsQuery.error ?? objectsQuery.error;
return (
<section className="page-stack repositories-page" aria-labelledby="repositories-heading">
<div className="hero-dashboard compact-hero">
<div>
<p className="eyebrow">Repository browser · live query service</p>
<h2 id="repositories-heading">Repository / publication point / object browser</h2>
<p>Browse repo trees first, then expand publication points and object rows only on demand.</p>
</div>
<div className="hero-status">
<Database size={20} />
<div>
<strong>{latestRunQuery.data?.data.runId ?? "Loading run"}</strong>
<span>{repos.length ? `${repos.length} repositories loaded` : "waiting for query service"}</span>
</div>
</div>
</div>
{error ? (
<div className="alert-banner" role="status">
<strong>Repository API unavailable</strong>
<span>{normalizeApiError(error).message}</span>
</div>
) : null}
<div className="repo-browser-grid">
<section className="panel list-panel" aria-label="Repositories">
<PanelTitle icon={<Database size={18} />} label="Repositories" meta={reposQuery.isFetching ? "loading" : `${repos.length} shown`} />
<div className="stack-list">
{repos.map((repo) => (
<button
className={`stack-row ${repo.repoId === selectedRepo?.repoId ? "active" : ""}`}
key={repo.repoId}
onClick={() => {
setSelectedRepoId(repo.repoId);
setSelectedPpId(null);
}}
type="button"
>
<span>
<strong>{repo.host}</strong>
<small>{repo.uri}</small>
</span>
<em>{repo.objects.toLocaleString()}</em>
<ChevronRight size={16} />
</button>
))}
</div>
</section>
<section className="panel list-panel" aria-label="Publication points">
<PanelTitle
icon={<GitBranch size={18} />}
label="Publication Points"
meta={ppsQuery.isFetching ? "loading" : `${publicationPoints.length} shown`}
/>
{!selectedRepo ? (
<div className="empty-state">Select a repository to load its publication points.</div>
) : null}
{ppsQuery.isFetching ? <LoadingLine /> : null}
<div className="stack-list">
{publicationPoints.map((pp) => (
<button
className={`stack-row ${pp.ppId === selectedPp?.ppId ? "active" : ""}`}
key={pp.ppId}
onClick={() => setSelectedPpId(pp.ppId)}
type="button"
>
<span>
<strong>{shortName(pp.manifestRsyncUri ?? pp.publicationPointRsyncUri ?? pp.rrdpNotificationUri ?? pp.ppId)}</strong>
<small>{pp.publicationPointRsyncUri ?? pp.rsyncBaseUri ?? pp.rrdpNotificationUri ?? "unknown"}</small>
</span>
<em>{pp.objects.toLocaleString()}</em>
<StatusPill state={pp.repoTerminalState ?? pp.source ?? "unknown"} />
</button>
))}
</div>
</section>
<section className="panel table-panel objects-live-panel" aria-label="Objects for publication point">
<PanelTitle
icon={<FileCode2 size={18} />}
label="Objects"
meta={objectsQuery.isFetching ? "loading" : `${objectsQuery.data?.data.length ?? 0} of selected PP`}
/>
{!selectedPp ? (
<div className="empty-state">Select a publication point to load its objects.</div>
) : null}
{objectsQuery.isFetching ? <LoadingLine /> : null}
{selectedPp && !objectsQuery.isFetching ? (
<ObjectTable objects={objectsQuery.data?.data ?? []} onOpenObject={onOpenObject} />
) : null}
<div className="pagination-note">
Default query uses <code>limit=50</code>. Next cursor: <code>{objectsQuery.data?.page?.nextCursor ?? "none"}</code>
</div>
</section>
</div>
</section>
);
}
function PanelTitle({ icon, label, meta }: { icon: ReactNode; label: string; meta: string }) {
return (
<div className="panel-heading compact-heading">
<div>
<p className="eyebrow">{label}</p>
<h3>{label}</h3>
</div>
<span className="panel-meta">
{icon}
{meta}
</span>
</div>
);
}
function ObjectTable({ objects, onOpenObject }: { objects: ObjectInstanceRecord[]; onOpenObject?: (objectInstanceId: string) => void }) {
if (objects.length === 0) {
return <div className="empty-state">No objects returned for this publication point.</div>;
}
return (
<table>
<thead>
<tr>
<th>Type</th>
<th>URI</th>
<th>Hash</th>
<th>Source</th>
<th>Result</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{objects.map((object) => (
<tr key={object.objectInstanceId}>
<td><span className="object-type">{object.objectType}</span></td>
<td className="uri-cell">{object.uri}</td>
<td>{object.sha256.slice(0, 12)}...</td>
<td>{object.sourceSection}</td>
<td><StatusPill state={object.rejected ? "rejected" : object.result} /></td>
<td>
<button className="table-link-button" onClick={() => onOpenObject?.(object.objectInstanceId)} type="button">
Open
</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
function StatusPill({ state }: { state: string }) {
const normalized = state.toLowerCase();
const className = normalized.includes("fail") || normalized.includes("reject") || normalized.includes("error")
? "warning"
: normalized.includes("cache") || normalized.includes("fallback")
? "fallback"
: "healthy";
return <span className={`status-badge ${className}`}>{state}</span>;
}
function LoadingLine() {
return (
<div className="loading-line">
<Loader2 size={16} />
Loading selected branch...
</div>
);
}
function shortName(uri: string) {
return uri.split("/").filter(Boolean).at(-1) ?? uri;
}

View File

@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { AppProviders } from "./app/providers";
import { App } from "./app/App";
import "./styles/globals.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<AppProviders>
<App />
</AppProviders>
</React.StrictMode>
);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
export const overviewKpis = [
{ label: "VRPs", value: "948,216", delta: "+12,834 (1.35%)", tone: "blue" },
{ label: "ASPAs", value: "4,812", delta: "+61 (2.95%)", tone: "cyan" },
{ label: "Repositories", value: "1,265", delta: "+8 (0.64%)", tone: "blue" },
{ label: "Publication Points", value: "92,438", delta: "+842 (1.63%)", tone: "purple" },
{ label: "Rejected Objects", value: "1,274", delta: "-31 (2.38%)", tone: "red" },
{ label: "Warnings", value: "3,219", delta: "+116 (3.75%)", tone: "amber" }
];
export const validationSlices = [
{ label: "Valid", value: 87, color: "#2563eb" },
{ label: "Warnings", value: 9, color: "#f59e0b" },
{ label: "Rejected", value: 4, color: "#ef4444" }
];
export const objectTypeRows = [
{ type: "ROA", count: "741,092", percent: 64 },
{ type: "CER / RC", count: "118,437", percent: 18 },
{ type: "MFT", count: "54,221", percent: 10 },
{ type: "CRL", count: "31,804", percent: 6 },
{ type: "ASPA", count: "4,812", percent: 2 }
];
export const topRepositories = [
{
host: "rpki.apnic.net",
transport: "RRDP",
duration: "12.8s",
objects: "228,192",
status: "healthy"
},
{
host: "rrdp.arin.net",
transport: "RRDP",
duration: "18.4s",
objects: "214,776",
status: "healthy"
},
{
host: "rpki.ripe.net",
transport: "RRDP",
duration: "22.1s",
objects: "308,427",
status: "warning"
},
{
host: "rpki-repo.registro.br",
transport: "RSYNC",
duration: "9.7s",
objects: "19,031",
status: "fallback"
}
];
export const validationIssues = [
{
severity: "high",
type: "MFT",
reason: "Manifest rejected by CRL validation",
uri: "rsync://rpki-repo.registro.br/repo/9mbS.../3kM7.mft",
repo: "rpki-repo.registro.br"
},
{
severity: "medium",
type: "ROA",
reason: "EE certificate expired",
uri: "rsync://rpki.example.net/repository/AS64496.roa",
repo: "rpki.example.net"
},
{
severity: "low",
type: "CRL",
reason: "Next update is close",
uri: "rsync://rpki.apnic.net/member_repository/example.crl",
repo: "rpki.apnic.net"
}
];
export const objectDetail = {
id: "obj_7d4b_00042",
type: "ROA",
result: "valid",
uri: "rsync://rpki.apnic.net/member_repository/A91ED7F4/AS64500-203.0.113.0-24.roa",
sha256: "7d4b9a0c7f8832d5f6a9807f4371b61cf3131b6fe0fd1a89c9e4eeb7e33b8872",
source: "fresh validated",
repo: "rpki.apnic.net",
pp: "rsync://rpki.apnic.net/member_repository/A91ED7F4/",
parsed: {
asn: "AS64500",
prefixes: ["203.0.113.0/24 maxLength 24", "2001:db8:1200::/48 maxLength 48"],
eeSubject: "CN=AS64500 ROA EE",
validity: "2026-06-15T00:00:00Z → 2026-06-22T00:00:00Z"
},
chain: [
{ label: "APNIC TAL", state: "trust anchor" },
{ label: "APNIC Root CA", state: "valid CA" },
{ label: "Member CA A91ED7F4", state: "valid CA" },
{ label: "Manifest", state: "current" },
{ label: "ROA object", state: "valid" }
],
manifestFiles: [
{ name: "AS64500-203.0.113.0-24.roa", hash: "7d4b9a0c…8872", state: "matched" },
{ name: "A91ED7F4.crl", hash: "91ee1db2…af09", state: "current" },
{ name: "A91ED7F4.cer", hash: "3acaa8f0…21aa", state: "valid" }
],
validation: [
{ label: "CMS signature", status: "passed", note: "Signed attributes and EE chain are valid." },
{ label: "Resource containment", status: "passed", note: "ROA prefixes are covered by parent resources." },
{ label: "Manifest membership", status: "passed", note: "Object hash matches the current manifest entry." },
{ label: "Revocation", status: "passed", note: "EE certificate is not listed in current CRL." }
]
};

View File

@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test";
test.setTimeout(120_000);
test("renders live object detail and lazy tab requests", async ({ page }) => {
const apiRequests: string[] = [];
const consoleErrors: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname.startsWith("/api/v1")) {
apiRequests.push(`${url.pathname}${url.search}`);
}
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
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();
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 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 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 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();
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
});
expect(consoleErrors).toEqual([]);
});

View File

@ -0,0 +1,39 @@
import { expect, test } from "@playwright/test";
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);
}
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
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.getByText("Top repositories by workload")).toBeVisible();
await expect(page.locator(".issue-card").first().getByText("Reject")).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(consoleErrors).toEqual([]);
await page.screenshot({
path: "../../../../specs/develop/20260617/m3_playwright/rpki-explorer-overview-live.png",
fullPage: true
});
});

View File

@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
test.setTimeout(120_000);
test("loads repository, publication point, and object data on demand", async ({ page }) => {
const apiRequests: string[] = [];
const consoleErrors: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname.startsWith("/api/v1")) {
apiRequests.push(`${url.pathname}${url.search}`);
}
});
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.goto("/");
await page.getByRole("button", { name: /Repositories/i }).click();
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);
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);
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 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();
expect(consoleErrors).toEqual([]);
await page.screenshot({
path: "../../../../specs/develop/20260617/m4_playwright/rpki-explorer-repository-browser.png",
fullPage: true
});
});

View File

@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
test("opens the RPKI Explorer overview prototype", async ({ page }) => {
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);
}
}
});
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.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
});
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);
}
}
});
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
});
expect(consoleErrors).toEqual([]);
});

View File

@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
test.setTimeout(120_000);
test("supports URI search and reports raw/export workflow status", async ({ page }) => {
const apiRequests: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname.startsWith("/api/v1")) {
apiRequests.push(`${request.method()} ${url.pathname}${url.search}`);
}
});
await page.goto("/");
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.getByPlaceholder("Exact URI lookup...").press("Enter");
await expect.poll(
() => apiRequests.some((request) => request.includes("/objects/by-uri")),
{ timeout: 30_000 }
).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 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 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 page.screenshot({
path: "../../../../specs/develop/20260617/m6_playwright/rpki-explorer-workflows.png",
fullPage: true
});
});

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts", "playwright.config.ts", "eslint.config.js"]
}

View File

@ -0,0 +1,29 @@
import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const queryServiceTarget = env.RPKI_EXPLORER_API_TARGET ?? "http://127.0.0.1:9557";
return {
plugins: [react()],
server: {
port: 5173,
strictPort: false,
proxy: {
"/api/v1": {
target: queryServiceTarget,
changeOrigin: true
}
}
},
preview: {
proxy: {
"/api/v1": {
target: queryServiceTarget,
changeOrigin: true
}
}
}
};
});