20260617 增加RPKI Explorer前端
This commit is contained in:
parent
6ef2c98890
commit
56f0d10dc6
5
.gitignore
vendored
5
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
81
ui/rpki-explorer/README.md
Normal file
81
ui/rpki-explorer/README.md
Normal 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
|
||||||
|
```
|
||||||
26
ui/rpki-explorer/eslint.config.js
Normal file
26
ui/rpki-explorer/eslint.config.js
Normal 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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
12
ui/rpki-explorer/index.html
Normal file
12
ui/rpki-explorer/index.html
Normal 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
3945
ui/rpki-explorer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
ui/rpki-explorer/package.json
Normal file
44
ui/rpki-explorer/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
ui/rpki-explorer/playwright.config.ts
Normal file
20
ui/rpki-explorer/playwright.config.ts
Normal 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"] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
101
ui/rpki-explorer/src/api/client.ts
Normal file
101
ui/rpki-explorer/src/api/client.ts
Normal 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");
|
||||||
|
}
|
||||||
301
ui/rpki-explorer/src/api/queryService.ts
Normal file
301
ui/rpki-explorer/src/api/queryService.ts
Normal 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}`);
|
||||||
|
}
|
||||||
35
ui/rpki-explorer/src/app/App.tsx
Normal file
35
ui/rpki-explorer/src/app/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
ui/rpki-explorer/src/app/providers.tsx
Normal file
7
ui/rpki-explorer/src/app/providers.tsx
Normal 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>;
|
||||||
|
}
|
||||||
15
ui/rpki-explorer/src/app/queryClient.ts
Normal file
15
ui/rpki-explorer/src/app/queryClient.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
66
ui/rpki-explorer/src/components/layout/Shell.tsx
Normal file
66
ui/rpki-explorer/src/components/layout/Shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
530
ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx
Normal file
530
ui/rpki-explorer/src/features/object-detail/ObjectDetailPage.tsx
Normal 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("; ");
|
||||||
|
}
|
||||||
347
ui/rpki-explorer/src/features/overview/OverviewPage.tsx
Normal file
347
ui/rpki-explorer/src/features/overview/OverviewPage.tsx
Normal 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());
|
||||||
|
}
|
||||||
224
ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx
Normal file
224
ui/rpki-explorer/src/features/repositories/RepositoriesPage.tsx
Normal 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;
|
||||||
|
}
|
||||||
13
ui/rpki-explorer/src/main.tsx
Normal file
13
ui/rpki-explorer/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
1171
ui/rpki-explorer/src/styles/globals.css
Normal file
1171
ui/rpki-explorer/src/styles/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
112
ui/rpki-explorer/src/test/fixtures/dashboard.ts
vendored
Normal file
112
ui/rpki-explorer/src/test/fixtures/dashboard.ts
vendored
Normal 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." }
|
||||||
|
]
|
||||||
|
};
|
||||||
50
ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts
Normal file
50
ui/rpki-explorer/tests/e2e/object-detail-api.spec.ts
Normal 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([]);
|
||||||
|
});
|
||||||
39
ui/rpki-explorer/tests/e2e/overview-api.spec.ts
Normal file
39
ui/rpki-explorer/tests/e2e/overview-api.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
51
ui/rpki-explorer/tests/e2e/repositories-api.spec.ts
Normal file
51
ui/rpki-explorer/tests/e2e/repositories-api.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
56
ui/rpki-explorer/tests/e2e/shell.spec.ts
Normal file
56
ui/rpki-explorer/tests/e2e/shell.spec.ts
Normal 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([]);
|
||||||
|
});
|
||||||
40
ui/rpki-explorer/tests/e2e/workflows-api.spec.ts
Normal file
40
ui/rpki-explorer/tests/e2e/workflows-api.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
21
ui/rpki-explorer/tsconfig.app.json
Normal file
21
ui/rpki-explorer/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
ui/rpki-explorer/tsconfig.json
Normal file
7
ui/rpki-explorer/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
14
ui/rpki-explorer/tsconfig.node.json
Normal file
14
ui/rpki-explorer/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
29
ui/rpki-explorer/vite.config.ts
Normal file
29
ui/rpki-explorer/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user