20260617 增加RPKI Explorer前端
This commit is contained in:
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." }
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user