20260718 重构 RPKI Explorer 查询界面

This commit is contained in:
2026-07-18 10:38:38 +08:00
parent a77982deaa
commit a5902c0a5e
79 changed files with 7964 additions and 4861 deletions

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { Check, Copy } from "lucide-react";
import { truncateMiddle } from "../lib/format";
/**
* Renders a long identifier (URI / hash / id) truncated with an always-available
* copy button. The full value is exposed via the `title` attribute.
*/
export function CopyableValue({
value,
max = 36,
label = "value",
}: {
value: string | null | undefined;
/** Max rendered characters before middle-truncation. */
max?: number;
/** Accessible description of what is copied, e.g. "object URI". */
label?: string;
}) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
if (!value) return <span className="text-faint">—</span>;
const onCopy = async (event: MouseEvent) => {
// Don't trigger row-level click handlers (navigation) when copying.
event.stopPropagation();
try {
await navigator.clipboard.writeText(value);
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API unavailable (permissions, non-secure context) — select
// fallback is intentionally omitted; the title attribute exposes the
// full value for manual copying.
setCopied(false);
}
};
return (
<span className="copyable" title={value}>
<span className="copyable-text">{truncateMiddle(value, max)}</span>
<button
type="button"
className={`copy-btn${copied ? " copied" : ""}`}
onClick={onCopy}
aria-label={`Copy ${label}`}
aria-live="polite"
>
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />}
</button>
</span>
);
}

View File

@@ -0,0 +1,48 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
/**
* Previous/Next controls for cursor-paged lists.
*/
export function CursorPagerControls({
page,
canPrev,
hasNext,
loading,
onPrev,
onNext,
itemCount,
}: {
page: number;
canPrev: boolean;
hasNext: boolean;
loading?: boolean;
onPrev: () => void;
onNext: () => void;
itemCount?: number;
}) {
return (
<div className="pager">
<span className="pager-info">
Page {page}
{itemCount !== undefined ? ` · ${itemCount} rows` : ""}
{loading ? " · loading…" : ""}
</span>
<button
type="button"
className="btn small"
onClick={onPrev}
disabled={!canPrev || loading}
>
<ChevronLeft size={13} aria-hidden="true" /> Previous
</button>
<button
type="button"
className="btn small"
onClick={onNext}
disabled={!hasNext || loading}
>
Next <ChevronRight size={13} aria-hidden="true" />
</button>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import type { ReactNode } from "react";
import { EmptyBlock, ErrorBlock } from "./StateBlock";
export interface Column<T> {
key: string;
header: ReactNode;
render: (row: T) => ReactNode;
/** Right-align (numeric) cells. */
numeric?: boolean;
/** Extra class on the td/th. */
className?: string;
}
interface DataTableProps<T> {
columns: Column<T>[];
rows: T[];
rowKey: (row: T) => string;
loading?: boolean;
error?: unknown;
onRetry?: () => void;
emptyTitle?: string;
emptyHint?: string;
caption?: string;
/** Row click handler — renders rows as clickable. */
onRowClick?: (row: T) => void;
/** Number of skeleton rows while loading. */
skeletonRows?: number;
}
/**
* Data-dense table with unified loading / error / empty states.
* Renders inside a horizontal-scroll wrapper so narrow viewports never
* overflow the document.
*/
export function DataTable<T>({
columns,
rows,
rowKey,
loading = false,
error,
onRetry,
emptyTitle = "No rows",
emptyHint,
caption,
onRowClick,
skeletonRows = 6,
}: DataTableProps<T>) {
if (error !== undefined && error !== null) {
return <ErrorBlock error={error} onRetry={onRetry} />;
}
if (loading) {
return (
<div role="status" aria-label="Loading table rows">
{Array.from({ length: skeletonRows }, (_, i) => (
<div className="skeleton-row" key={i} />
))}
</div>
);
}
if (rows.length === 0) {
return <EmptyBlock title={emptyTitle} hint={emptyHint} />;
}
return (
<div className="table-wrap">
<table className="data-table">
{caption ? <caption className="sr-only">{caption}</caption> : null}
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
scope="col"
className={`${col.numeric ? "num" : ""} ${col.className ?? ""}`.trim()}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={rowKey(row)}
className={onRowClick ? "clickable" : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => (
<td
key={col.key}
className={`${col.numeric ? "num" : ""} ${col.className ?? ""}`.trim()}
>
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
export function KpiCard({
label,
value,
sub,
tone,
to,
}: {
label: string;
value: ReactNode;
sub?: ReactNode;
tone?: "red" | "amber" | "blue";
to?: string;
}) {
const body = (
<>
<span className="kpi-label">{label}</span>
<span className="kpi-value">{value}</span>
{sub ? <span className="kpi-sub">{sub}</span> : null}
</>
);
const className = `kpi-card${tone ? ` tone-${tone}` : ""}`;
if (to) {
return (
<Link to={to} className={className}>
{body}
</Link>
);
}
return <div className={className}>{body}</div>;
}

View File

@@ -0,0 +1,218 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import type { ListResult, ObjectInstanceRecord } from "../api/schemas";
import type { ObjectFilters } from "../api/service";
import { useCursorPager } from "../lib/cursor";
import { objectTypeLabel, truncateMiddle } from "../lib/format";
import { withRunParam } from "../lib/run";
import { CopyableValue } from "./CopyableValue";
import { CursorPagerControls } from "./CursorPagerControls";
import { DataTable, type Column } from "./DataTable";
import { StatusPill } from "./StatusPill";
export interface ObjectFilterValues {
type: string;
result: string;
rejectedOnly: boolean;
q: string;
}
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
type: "",
result: "",
rejectedOnly: false,
q: "",
};
/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
const RESULT_OPTIONS = ["ok", "error", "skipped"];
/**
* Filter bar + paginated object table. The `fetcher` determines the scope
* (global / repo / publication point / issues). Filter state is controlled
* by the caller so standalone pages can sync it to the URL.
*/
export function ObjectsTable({
runId,
queryKeyScope,
fetcher,
filters,
onFiltersChange,
showReason = true,
}: {
runId: string;
queryKeyScope: string;
fetcher: (filters: ObjectFilters) => Promise<ListResult<ObjectInstanceRecord>>;
filters: ObjectFilterValues;
onFiltersChange: (next: ObjectFilterValues) => void;
showReason?: boolean;
}) {
const navigate = useNavigate();
const [qDraft, setQDraft] = useState(filters.q);
// Keep the draft input in sync when filters change externally (URL nav).
useEffect(() => {
setQDraft(filters.q);
}, [filters.q]);
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]);
const pager = useCursorPager(filterKey);
const query = useQuery({
queryKey: ["objects", queryKeyScope, runId, filters, pager.cursor],
queryFn: () =>
fetcher({
limit: 50,
cursor: pager.cursor,
type: filters.type || undefined,
result: filters.result || undefined,
rejected: filters.rejectedOnly || undefined,
q: filters.q || undefined,
}),
placeholderData: (prev) => prev,
});
const columns: Column<ObjectInstanceRecord>[] = useMemo(
() => [
{
key: "type",
header: "Type",
render: (obj) => <span className="chip">{objectTypeLabel(obj.objectType)}</span>,
},
{
key: "uri",
header: "URI",
render: (obj) => <CopyableValue value={obj.uri} max={64} label="object URI" />,
},
{
key: "sha256",
header: "SHA-256",
render: (obj) => (
<span className="mono text-muted" title={obj.sha256 ?? undefined}>
{obj.sha256 ? truncateMiddle(obj.sha256, 18) : "—"}
</span>
),
},
{
key: "source",
header: "Source",
render: (obj) => <StatusPill status={obj.sourceSection} />,
},
{
key: "result",
header: "Result",
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
},
...(showReason
? [
{
key: "reason",
header: "Reject reason",
render: (obj: ObjectInstanceRecord) =>
obj.rejectReason ? (
<span className="mono text-small" title={obj.rejectReason}>
{truncateMiddle(obj.rejectReason, 60)}
</span>
) : (
<span className="text-faint">—</span>
),
} satisfies Column<ObjectInstanceRecord>,
]
: []),
],
[showReason],
);
const submitQ = () => {
if (qDraft.trim() !== filters.q) {
onFiltersChange({ ...filters, q: qDraft.trim() });
}
};
return (
<div>
<div className="panel-header filter-bar" role="search">
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-type`}>Type</label>
<select
id={`${queryKeyScope}-type`}
value={filters.type}
onChange={(e) => onFiltersChange({ ...filters, type: e.target.value })}
>
<option value="">all types</option>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{objectTypeLabel(t)}
</option>
))}
</select>
</div>
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-result`}>Result</label>
<select
id={`${queryKeyScope}-result`}
value={filters.result}
onChange={(e) => onFiltersChange({ ...filters, result: e.target.value })}
>
<option value="">all results</option>
{RESULT_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
<input
id={`${queryKeyScope}-q`}
type="search"
className="mono"
value={qDraft}
onChange={(e) => setQDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitQ();
}}
onBlur={submitQ}
placeholder="substring…"
/>
</div>
<label className="filter-check">
<input
type="checkbox"
checked={filters.rejectedOnly}
onChange={(e) => onFiltersChange({ ...filters, rejectedOnly: e.target.checked })}
/>
Rejected only
</label>
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
server-side filters
</span>
</div>
<DataTable
columns={columns}
rows={query.data?.items ?? []}
rowKey={(obj) => obj.objectInstanceId}
loading={query.isPending}
error={query.isError ? query.error : undefined}
onRetry={() => query.refetch()}
emptyTitle="No objects match"
emptyHint="Try relaxing the filters."
caption="RPKI objects"
onRowClick={(obj) =>
navigate(withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId))
}
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={query.data?.nextCursor != null}
loading={query.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(query.data?.nextCursor ?? null)}
itemCount={query.data?.items.length}
/>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
export function PageHeader({
title,
subtitle,
actions,
}: {
title: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div className="page-title-block">
<h1>{title}</h1>
{subtitle ? <div className="page-subtitle">{subtitle}</div> : null}
</div>
{actions ? <div className="page-actions">{actions}</div> : null}
</header>
);
}

View File

@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
/** Panel with header (title + optional tools) and body. */
export function Panel({
title,
subtitle,
tools,
children,
flush = false,
className = "",
}: {
title?: ReactNode;
subtitle?: ReactNode;
tools?: ReactNode;
children: ReactNode;
/** Remove body padding (for tables that span the full width). */
flush?: boolean;
className?: string;
}) {
return (
<section className={`panel ${className}`.trim()}>
{title || tools ? (
<header className="panel-header">
<div>
{title ? <h2>{title}</h2> : null}
{subtitle ? <div className="panel-subtitle">{subtitle}</div> : null}
</div>
{tools ? <div className="panel-tools">{tools}</div> : null}
</header>
) : null}
<div className={`panel-body${flush ? " flush" : ""}`}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,305 @@
import { useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { listManifestFiles, listRevokedCertificates } from "../api/service";
import type { ProjectionRecord } from "../api/schemas";
import { CopyableValue } from "./CopyableValue";
import { CursorPagerControls } from "./CursorPagerControls";
import { DataTable, type Column } from "./DataTable";
import { EmptyBlock, LoadingBlock, Notice } from "./StateBlock";
import { useCursorPager } from "../lib/cursor";
import { formatInt, formatUtc } from "../lib/format";
import { asArray, asNumber, asRecord, asString } from "../lib/projection";
function MetaRow({ label, value, mono = false }: { label: string; value: ReactNode; mono?: boolean }) {
return (
<>
<dt>{label}</dt>
<dd className={mono ? "mono" : undefined}>{value ?? <span className="text-faint">—</span>}</dd>
</>
);
}
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
const query = useQuery({
queryKey: ["manifest-files", runId, objectInstanceId, pager.cursor],
queryFn: () => listManifestFiles(runId, objectInstanceId, { limit: 100, cursor: pager.cursor }),
placeholderData: (prev) => prev,
});
const columns: Column<{ fileName: string; hashHex: string }>[] = [
{ key: "file", header: "File", render: (row) => <span className="mono">{row.fileName}</span> },
{
key: "hash",
header: "Hash",
render: (row) => <CopyableValue value={row.hashHex} max={40} label="file hash" />,
},
];
if (query.isPending) return <LoadingBlock label="Loading manifest file list…" />;
if (query.isError) return <Notice kind="error">Failed to load the manifest file list.</Notice>;
return (
<>
<DataTable
columns={columns}
rows={query.data.items}
rowKey={(row) => row.fileName}
emptyTitle="Manifest has no file entries"
caption="Manifest file list"
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={query.data.nextCursor != null}
loading={query.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(query.data.nextCursor ?? null)}
itemCount={query.data.items.length}
/>
</>
);
}
function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
const pager = useCursorPager(`${runId}:${objectInstanceId}:crl`);
const query = useQuery({
queryKey: ["revoked-certs", runId, objectInstanceId, pager.cursor],
queryFn: () =>
listRevokedCertificates(runId, objectInstanceId, { limit: 100, cursor: pager.cursor }),
placeholderData: (prev) => prev,
});
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [
{
key: "serial",
header: "Serial number",
render: (row) => (
<CopyableValue value={row.serialNumberHex ?? row.serialNumber} max={44} label="serial number" />
),
},
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
];
if (query.isPending) return <LoadingBlock label="Loading revoked certificates…" />;
if (query.isError) return <Notice kind="error">Failed to load the revocation list.</Notice>;
const rows = query.data.items.map((row, index) => ({
...row,
_key: `${row.serialNumberHex ?? row.serialNumber ?? "entry"}-${index}`,
}));
return (
<>
<DataTable
columns={columns}
rows={rows}
rowKey={(row) => row._key}
emptyTitle="No revoked certificates"
caption="Revoked certificates"
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={query.data.nextCursor != null}
loading={query.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(query.data.nextCursor ?? null)}
itemCount={query.data.items.length}
/>
</>
);
}
function RoaView({ projection }: { projection: Record<string, unknown> }) {
const roa = asRecord(projection.roa);
const families = asArray(roa?.ipAddressFamilies);
return (
<>
<dl className="meta-grid" style={{ marginBottom: 16 }}>
<MetaRow label="AS ID" value={asNumber(roa?.asId) ?? asString(roa?.asId)} mono />
</dl>
{families.map((family, i) => {
const fam = asRecord(family);
const addresses = asArray(fam?.addresses);
return (
<div key={i}>
<h3 className="text-small" style={{ margin: "12px 0 6px" }}>
Address family {asString(fam?.afi) ?? `#${i + 1}`}
</h3>
<DataTable
columns={[
{
key: "prefix",
header: "Prefix",
render: (row: Record<string, unknown>) => (
<span className="mono">{asString(row.prefix) ?? "—"}</span>
),
},
{
key: "maxLength",
header: "maxLength",
numeric: true,
render: (row: Record<string, unknown>) => asNumber(row.maxLength) ?? "—",
},
]}
rows={addresses.map((a) => asRecord(a) ?? {})}
rowKey={(row) => `${asString(row.prefix)}-${asNumber(row.maxLength)}`}
emptyTitle="No prefixes"
/>
</div>
);
})}
</>
);
}
function AspaView({ projection }: { projection: Record<string, unknown> }) {
const aspa = asRecord(projection.aspa);
const providers = asArray(aspa?.providerAsIds).map((p) => asNumber(p) ?? asString(p));
return (
<dl className="meta-grid">
<MetaRow label="Customer AS" value={asNumber(aspa?.customerAsId) ?? asString(aspa?.customerAsId)} mono />
<MetaRow
label="Provider ASes"
value={
providers.length ? (
<span className="chip-list">
{providers.map((p, i) => (
<span className="chip" key={i}>AS{String(p)}</span>
))}
</span>
) : (
"—"
)
}
/>
</dl>
);
}
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
const cert = asRecord(projection.certificate) ?? asRecord(projection.cer) ?? projection;
const validity = asRecord(cert.validity);
const resources = asRecord(cert.resources);
const ipBlocks = asArray(resources?.ipResources ?? resources?.ipv4 ?? resources?.addresses);
const asBlocks = asArray(resources?.asResources ?? resources?.asns);
return (
<dl className="meta-grid">
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
<MetaRow label="Issuer" value={asString(cert.issuer)} mono />
<MetaRow label="Subject" value={asString(cert.subject)} mono />
<MetaRow
label="Validity"
value={`${asString(validity?.notBefore) ?? "—"} → ${asString(validity?.notAfter) ?? "—"}`}
mono
/>
<MetaRow label="SKI" value={<CopyableValue value={asString(cert.ski)} max={56} label="SKI" />} />
<MetaRow label="AKI" value={<CopyableValue value={asString(cert.aki)} max={56} label="AKI" />} />
<MetaRow label="SIA" value={<CopyableValue value={asString(cert.sia)} max={72} label="SIA" />} />
<MetaRow label="AIA" value={<CopyableValue value={asString(cert.aia)} max={72} label="AIA" />} />
<MetaRow label="CRL DP" value={<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />} />
<MetaRow
label="IP resources"
value={
ipBlocks.length ? (
<span className="chip-list">
{ipBlocks.slice(0, 12).map((b, i) => (
<span className="chip" key={i}>{typeof b === "string" ? b : JSON.stringify(b)}</span>
))}
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
</span>
) : (
"—"
)
}
/>
<MetaRow
label="AS resources"
value={
asBlocks.length ? (
<span className="chip-list">
{asBlocks.slice(0, 12).map((b, i) => (
<span className="chip" key={i}>{typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b)}</span>
))}
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
</span>
) : (
"—"
)
}
/>
<MetaRow label="Signature algorithm" value={asString(cert.signatureAlgorithm)} mono />
</dl>
);
}
/**
* Structured rendering of a parsed object projection, per object type.
*/
export function ProjectionView({
record,
runId,
objectInstanceId,
}: {
record: ProjectionRecord | null;
runId: string;
objectInstanceId: string;
}) {
if (!record || !record.projection) {
return (
<EmptyBlock
title="Parsed projection unavailable"
hint="The query service needs --repo-bytes-db to build parsed projections for this object."
/>
);
}
if (record.parseStatus === "error") {
return <Notice kind="error">Projection failed: {record.errorSummary ?? "unknown parse error"}</Notice>;
}
const projection = record.projection;
const type = (record.objectType ?? "").toLowerCase();
if (type === "roa") return <RoaView projection={projection} />;
if (type === "asa" || type === "aspa") return <AspaView projection={projection} />;
if (type === "mft") {
const manifest = asRecord(projection.manifest);
return (
<>
<dl className="meta-grid" style={{ marginBottom: 16 }}>
<MetaRow label="Manifest number" value={asString(manifest?.manifestNumberHex ?? manifest?.manifestNumber)} mono />
<MetaRow label="thisUpdate" value={formatUtc(asString(manifest?.thisUpdate))} />
<MetaRow label="nextUpdate" value={formatUtc(asString(manifest?.nextUpdate))} />
<MetaRow label="File hash algorithm" value={asString(manifest?.fileHashAlg)} mono />
<MetaRow label="File count" value={formatInt(asNumber(manifest?.fileCount))} />
</dl>
<ManifestFiles runId={runId} objectInstanceId={objectInstanceId} />
</>
);
}
if (type === "crl") {
const crl = asRecord(projection.crl);
return (
<>
<dl className="meta-grid" style={{ marginBottom: 16 }}>
<MetaRow label="Issuer" value={asString(crl?.issuer)} mono />
<MetaRow label="thisUpdate" value={formatUtc(asString(crl?.thisUpdate))} />
<MetaRow label="nextUpdate" value={formatUtc(asString(crl?.nextUpdate))} />
<MetaRow label="CRL number" value={asString(crl?.crlNumberHex ?? crl?.crlNumber)} mono />
<MetaRow label="AKI" value={<CopyableValue value={asString(crl?.aki)} max={56} label="AKI" />} />
</dl>
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
</>
);
}
if (type === "cer" || type === "ee" || type === "router_cert") {
return <CertificateView projection={projection} />;
}
return (
<pre className="json-block" data-testid="projection-json">
{JSON.stringify(projection, null, 2)}
</pre>
);
}

View File

@@ -0,0 +1,176 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Braces,
FileBox,
FolderTree,
History,
LayoutDashboard,
PackageOpen,
PanelLeftClose,
PanelLeftOpen,
Search as SearchIcon,
Server,
ShieldCheck,
} from "lucide-react";
import { getHealth, listRuns } from "../api/service";
import { LATEST_RUN, useRunParam, useSetRunParam } from "../lib/run";
import { StatusPill } from "./StatusPill";
const NAV_ITEMS = [
{ path: "/", label: "Overview", icon: LayoutDashboard, end: true },
{ path: "/repositories", label: "Repositories", icon: Server, end: false },
{ path: "/publication-points", label: "Publication Points", icon: FolderTree, end: false },
{ path: "/objects", label: "Objects", icon: FileBox, end: false },
{ path: "/validation", label: "Validation", icon: ShieldCheck, end: false },
{ path: "/exports", label: "Exports", icon: PackageOpen, end: false },
{ path: "/runs", label: "Runs", icon: History, end: false },
{ path: "/api", label: "API", icon: Braces, end: false },
];
const COLLAPSE_KEY = "rpki-explorer.sidebar-collapsed";
function ConnectionPill() {
const healthQuery = useQuery({
queryKey: ["health"],
queryFn: getHealth,
refetchInterval: 60_000,
});
if (healthQuery.isPending) {
return <StatusPill status="pending" label="connecting" />;
}
if (healthQuery.isError) {
return <StatusPill status="error" label="API unreachable" />;
}
const health = healthQuery.data;
return (
<span title={health.latestReadyRun ? `Latest indexed run: ${health.latestReadyRun}` : undefined}>
<StatusPill status="ok" label="connected" />
</span>
);
}
function RunSelector() {
const runParam = useRunParam();
const setRunParam = useSetRunParam();
const runsQuery = useQuery({
queryKey: ["runs", "selector"],
queryFn: () => listRuns({ limit: 25 }),
staleTime: 60_000,
});
const healthQuery = useQuery({ queryKey: ["health"], queryFn: getHealth });
const latestId = healthQuery.data?.latestReadyRun ?? runsQuery.data?.items[0]?.runId;
return (
<div className="topbar-run">
<label htmlFor="run-selector">Run</label>
<select
id="run-selector"
value={runParam ?? ""}
onChange={(event) => setRunParam(event.target.value || null)}
>
<option value="">{latestId ? `latest (${latestId})` : LATEST_RUN}</option>
{(runsQuery.data?.items ?? []).map((run) => (
<option key={run.runId} value={run.runId}>
{run.runId}
</option>
))}
</select>
</div>
);
}
function TopbarSearch() {
const navigate = useNavigate();
const runParam = useRunParam();
const [value, setValue] = useState("");
const onSubmit = (event: FormEvent) => {
event.preventDefault();
const q = value.trim();
if (!q) return;
const params = new URLSearchParams({ q });
if (runParam) params.set("run", runParam);
navigate(`/search?${params.toString()}`);
};
return (
<form className="topbar-search" role="search" onSubmit={onSubmit}>
<span className="search-icon" aria-hidden="true">
<SearchIcon size={14} />
</span>
<input
type="search"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Search URI, SHA-256, repository host…"
aria-label="Global search"
/>
</form>
);
}
export function Shell({ children }: { children: ReactNode }) {
const runParam = useRunParam();
const [collapsed, setCollapsed] = useState(
() => localStorage.getItem(COLLAPSE_KEY) === "1",
);
const toggleCollapsed = () => {
setCollapsed((prev) => {
localStorage.setItem(COLLAPSE_KEY, prev ? "0" : "1");
return !prev;
});
};
const search = runParam ? `?run=${encodeURIComponent(runParam)}` : "";
return (
<div className={`shell${collapsed ? " collapsed" : ""}`}>
<div className="shell-brand">
<span className="brand-badge" aria-hidden="true">
<ShieldCheck size={15} />
</span>
<span className="brand-name">RPKI Explorer</span>
</div>
<div className="shell-topbar">
<TopbarSearch />
<RunSelector />
<ConnectionPill />
</div>
<nav className="shell-nav" aria-label="Primary">
<button
type="button"
className="nav-toggle"
onClick={toggleCollapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? (
<PanelLeftOpen size={15} aria-hidden="true" />
) : (
<PanelLeftClose size={15} aria-hidden="true" />
)}
{!collapsed && <span>Collapse</span>}
</button>
{NAV_ITEMS.map((item) => (
<NavLink
key={item.path}
to={{ pathname: item.path, search }}
end={item.end}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
<item.icon size={15} aria-hidden="true" />
<span className="nav-label">{item.label}</span>
</NavLink>
))}
<div className="nav-footer">ours RP query layer</div>
</nav>
<main className="shell-main">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { AlertTriangle, Inbox, RefreshCw } from "lucide-react";
import { ApiError } from "../api/client";
export function LoadingBlock({ label = "Loading…" }: { label?: string }) {
return (
<div className="state-block" role="status" aria-live="polite">
<span className="spinner" aria-hidden="true" />
<span>{label}</span>
</div>
);
}
export function ErrorBlock({
error,
onRetry,
title = "Failed to load data",
}: {
error: unknown;
onRetry?: () => void;
title?: string;
}) {
const message =
error instanceof ApiError
? `${error.message}${error.status ? ` (HTTP ${error.status})` : ""}`
: error instanceof Error
? error.message
: "Unknown error";
return (
<div className="banner error" role="alert">
<AlertTriangle size={16} aria-hidden="true" />
<div className="banner-body">
<strong>{title}.</strong> {message}
</div>
{onRetry ? (
<button type="button" className="btn small" onClick={onRetry}>
<RefreshCw size={13} aria-hidden="true" /> Retry
</button>
) : null}
</div>
);
}
export function EmptyBlock({
title = "No data",
hint,
}: {
title?: string;
hint?: string;
}) {
return (
<div className="state-block">
<Inbox size={22} aria-hidden="true" />
<span className="state-title">{title}</span>
{hint ? <span className="text-muted text-small">{hint}</span> : null}
</div>
);
}
/** Inline warning/info banner. */
export function Notice({
kind,
children,
}: {
kind: "info" | "warning" | "error";
children: React.ReactNode;
}) {
return (
<div className={`banner ${kind}`} role={kind === "error" ? "alert" : "status"}>
<AlertTriangle size={16} aria-hidden="true" />
<div className="banner-body">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
/**
* Status pill with a deterministic status → tone mapping. The label is
* always rendered as text (status never relies on color alone).
*/
type Tone = "green" | "amber" | "red" | "blue" | "slate";
const TONE_BY_STATUS: Record<string, Tone> = {
ok: "green",
valid: "green",
ready: "green",
complete: "green",
fresh: "green",
success: "green",
linked: "green",
connected: "green",
running: "blue",
building: "amber",
warnings: "amber",
warning: "amber",
cached: "amber",
partial: "amber",
stale: "amber",
error: "red",
invalid: "red",
failed: "red",
rejected: "red",
failed_no_cache: "red",
missing: "red",
skipped: "slate",
unknown: "slate",
pending: "slate",
};
export function toneForStatus(status: string | null | undefined): Tone {
if (!status) return "slate";
return TONE_BY_STATUS[status.toLowerCase()] ?? "slate";
}
export function StatusPill({
status,
label,
}: {
status: string | null | undefined;
label?: string;
}) {
const text = label ?? status ?? "unknown";
return (
<span className={`pill tone-${toneForStatus(status)}`}>
<span className="pill-dot" aria-hidden="true" />
{text}
</span>
);
}

View File

@@ -0,0 +1,79 @@
import { useRef } from "react";
import type { KeyboardEvent, ReactNode } from "react";
export interface TabItem {
id: string;
label: ReactNode;
}
/**
* Accessible tab strip with roving tabindex and arrow-key navigation.
* Controlled: the parent owns the active id (usually synced to the URL).
*/
export function Tabs({
tabs,
active,
onChange,
ariaLabel,
}: {
tabs: TabItem[];
active: string;
onChange: (id: string) => void;
ariaLabel: string;
}) {
const refs = useRef(new Map<string, HTMLButtonElement>());
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
const index = tabs.findIndex((t) => t.id === active);
if (index < 0) return;
const delta = event.key === "ArrowRight" ? 1 : -1;
const next = tabs[(index + delta + tabs.length) % tabs.length];
event.preventDefault();
onChange(next.id);
refs.current.get(next.id)?.focus();
};
return (
<div className="tabs" role="tablist" aria-label={ariaLabel} onKeyDown={onKeyDown}>
{tabs.map((tab) => {
const selected = tab.id === active;
return (
<button
key={tab.id}
ref={(el) => {
if (el) refs.current.set(tab.id, el);
else refs.current.delete(tab.id);
}}
type="button"
role="tab"
id={`tab-${tab.id}`}
aria-selected={selected}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(tab.id)}
>
{tab.label}
</button>
);
})}
</div>
);
}
export function TabPanel({
id,
active,
children,
}: {
id: string;
active: string;
children: ReactNode;
}) {
if (id !== active) return null;
return (
<div role="tabpanel" id={`tabpanel-${id}`} aria-labelledby={`tab-${id}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { CheckCircle2, XCircle } from "lucide-react";
import type { ExportJobRecord } from "../api/schemas";
import { exportDownloadUrl } from "../api/service";
import { formatInt } from "../lib/format";
/**
* Presentational status line for an async export job. Polling is the
* caller's responsibility (react-query `refetchInterval`).
*/
export function WorkflowStatus({
job,
runId,
}: {
job: ExportJobRecord;
runId: string;
}) {
if (job.status === "complete") {
return (
<span className="workflow-status" role="status">
<CheckCircle2 size={15} aria-hidden="true" color="var(--green-600)" />
<span>
Export ready · {formatInt(job.objectCount)} objects
</span>
<a className="btn small primary" href={exportDownloadUrl(runId, job.jobId)} download>
Download tar
</a>
</span>
);
}
if (job.status === "failed") {
return (
<span className="workflow-status" role="alert">
<XCircle size={15} aria-hidden="true" color="var(--red-600)" />
<span>Export failed{job.error ? `: ${job.error}` : ""}</span>
</span>
);
}
return (
<span className="workflow-status" role="status" aria-live="polite">
<span className="spinner small" aria-hidden="true" />
<span>Export running…</span>
</span>
);
}

View File

@@ -1,88 +0,0 @@
import { Check, Copy } from "lucide-react";
import { useEffect, useState } from "react";
interface CopyableValueProps {
className?: string;
displayValue?: string;
label: string;
value: string;
}
interface TooltipPosition {
left: number;
top: number;
}
export function CopyableValue({ className, displayValue, label, value }: CopyableValueProps) {
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const [tooltipPosition, setTooltipPosition] = useState<TooltipPosition | null>(null);
useEffect(() => {
if (copyState === "idle") {
return;
}
const timeout = window.setTimeout(() => setCopyState("idle"), 1600);
return () => window.clearTimeout(timeout);
}, [copyState]);
const showTooltip = (target: HTMLElement) => {
const rect = target.getBoundingClientRect();
setTooltipPosition({
left: Math.min(window.innerWidth - 24, Math.max(24, rect.left + rect.width / 2)),
top: rect.top > 90 ? rect.top - 10 : rect.bottom + 10
});
};
const copy = async () => {
try {
await copyText(value);
setCopyState("copied");
} catch {
setCopyState("failed");
}
};
return (
<span className={`copyable-value ${className ?? ""}`} data-copy-state={copyState} data-copyable-label={label}>
<span
className="copyable-value-text"
onBlur={() => setTooltipPosition(null)}
onFocus={(event) => showTooltip(event.currentTarget)}
onMouseEnter={(event) => showTooltip(event.currentTarget)}
onMouseLeave={() => setTooltipPosition(null)}
tabIndex={0}
title={value}
>
{displayValue ?? value}
</span>
<button aria-label={`Copy ${label}`} className="copy-icon-button" onClick={copy} title={`Copy ${label}`} type="button">
{copyState === "copied" ? <Check aria-hidden="true" size={13} /> : <Copy aria-hidden="true" size={13} />}
</button>
{tooltipPosition ? (
<span className="copyable-tooltip" role="tooltip" style={{ left: tooltipPosition.left, top: tooltipPosition.top }}>
{value}
</span>
) : null}
{copyState !== "idle" ? <span className="copy-state-text">{copyState === "copied" ? "Copied" : "Copy failed"}</span> : null}
</span>
);
}
async function copyText(value: string) {
if (navigator.clipboard) {
await navigator.clipboard.writeText(value);
return;
}
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
} finally {
document.body.removeChild(textArea);
}
}

View File

@@ -1,32 +0,0 @@
interface CursorPagerProps {
label?: string;
isFetching: boolean;
nextCursor: string | null;
onNext: () => void;
onPrevious: () => void;
pageNumber: number;
previousCount: number;
rangeLabel?: string;
}
export function CursorPager({
label,
isFetching,
nextCursor,
onNext,
onPrevious,
pageNumber,
previousCount,
rangeLabel
}: CursorPagerProps) {
return (
<div className="cursor-pager" aria-label="Cursor pagination">
<button disabled={isFetching || previousCount === 0} onClick={onPrevious} type="button">Previous</button>
<span>
{label ?? `Page ${pageNumber}`}
{rangeLabel ? <small>{rangeLabel}</small> : null}
</span>
<button disabled={isFetching || !nextCursor} onClick={onNext} type="button">Next</button>
</div>
);
}

View File

@@ -1,24 +0,0 @@
export interface PageState {
cursor: string | null;
previous: Array<string | null>;
}
export function advancePage(page: PageState, nextCursor: string | null): PageState {
if (!nextCursor) {
return page;
}
return {
cursor: nextCursor,
previous: [...page.previous, page.cursor]
};
}
export function previousPage(page: PageState): PageState {
if (page.previous.length === 0) {
return page;
}
return {
cursor: page.previous[page.previous.length - 1] ?? null,
previous: page.previous.slice(0, -1)
};
}

View File

@@ -1,78 +0,0 @@
import type { LucideIcon } from "lucide-react";
import { PanelLeftClose, PanelLeftOpen, Search } from "lucide-react";
import type { PropsWithChildren } from "react";
import { useState } from "react";
export interface NavigationItem<Id extends string = string> {
id: Id;
label: string;
icon: LucideIcon;
}
interface ShellProps<Id extends string> extends PropsWithChildren {
activeView: Id;
navigationItems: Array<NavigationItem<Id>>;
onNavigate: (id: Id) => void;
}
export function Shell<Id extends string>({ activeView, navigationItems, onNavigate, children }: ShellProps<Id>) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
return (
<div className={`app-shell ${isSidebarCollapsed ? "sidebar-collapsed" : ""}`}>
<aside className="sidebar" aria-label="Primary navigation">
<div className="brand">
<div className="brand-mark">R</div>
<div className="brand-copy">
<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}
aria-label={isSidebarCollapsed ? item.label : undefined}
className={`nav-item ${activeView === item.id ? "active" : ""}`}
key={item.id}
onClick={() => onNavigate(item.id)}
title={isSidebarCollapsed ? item.label : undefined}
type="button"
>
<item.icon aria-hidden="true" size={18} />
<span>{item.label}</span>
</button>
))}
</nav>
<button
aria-expanded={!isSidebarCollapsed}
aria-label={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
className="sidebar-toggle"
onClick={() => setIsSidebarCollapsed((collapsed) => !collapsed)}
title={isSidebarCollapsed ? "Expand navigation" : "Collapse navigation"}
type="button"
>
{isSidebarCollapsed ? <PanelLeftOpen aria-hidden="true" size={18} /> : <PanelLeftClose aria-hidden="true" size={18} />}
<span>{isSidebarCollapsed ? "Expand" : "Collapse"}</span>
</button>
</aside>
<main className="main-panel">
<header className="topbar">
<div className="run-selector readonly-run" aria-label="Current run">
<span>Run</span>
<strong>latest indexed run</strong>
</div>
<div className="topbar-actions">
<label className="search-box">
<Search aria-hidden="true" size={18} />
<span className="sr-only">Exact object URI search is not active yet</span>
<input disabled placeholder="Exact URI lookup planned in M2" />
</label>
<div className="ready-pill"><span /> UI Ready</div>
</div>
</header>
{children}
</main>
</div>
);
}