20260718 重构 RPKI Explorer 查询界面
This commit is contained in:
61
ui/rpki-explorer/src/components/CopyableValue.tsx
Normal file
61
ui/rpki-explorer/src/components/CopyableValue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
ui/rpki-explorer/src/components/CursorPagerControls.tsx
Normal file
48
ui/rpki-explorer/src/components/CursorPagerControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
ui/rpki-explorer/src/components/DataTable.tsx
Normal file
104
ui/rpki-explorer/src/components/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
ui/rpki-explorer/src/components/KpiCard.tsx
Normal file
33
ui/rpki-explorer/src/components/KpiCard.tsx
Normal 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>;
|
||||
}
|
||||
218
ui/rpki-explorer/src/components/ObjectsTable.tsx
Normal file
218
ui/rpki-explorer/src/components/ObjectsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
ui/rpki-explorer/src/components/PageHeader.tsx
Normal file
21
ui/rpki-explorer/src/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
ui/rpki-explorer/src/components/Panel.tsx
Normal file
34
ui/rpki-explorer/src/components/Panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
ui/rpki-explorer/src/components/ProjectionView.tsx
Normal file
305
ui/rpki-explorer/src/components/ProjectionView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
ui/rpki-explorer/src/components/Shell.tsx
Normal file
176
ui/rpki-explorer/src/components/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
ui/rpki-explorer/src/components/StateBlock.tsx
Normal file
73
ui/rpki-explorer/src/components/StateBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
ui/rpki-explorer/src/components/StatusPill.tsx
Normal file
54
ui/rpki-explorer/src/components/StatusPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
ui/rpki-explorer/src/components/Tabs.tsx
Normal file
79
ui/rpki-explorer/src/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
ui/rpki-explorer/src/components/WorkflowStatus.tsx
Normal file
44
ui/rpki-explorer/src/components/WorkflowStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user