20260726 修复rpki-explorer体验问题32项(#130):RocksDB max_open_files+compose ulimits、objects列表流式早停+scope前置(每页2.85s->10ms级)、未知/过期run统一404、runs倒序、导出文件名Content-Disposition、路径段percent_decode、nginx /api/v1代理与400回SPA;前端serialNumber schema、30s请求超时、reason筛选、组合VRP查询、导出反馈与repo/PP入口、键盘可达、cursor进URL、分页/对比度/skip-link等体验项
This commit is contained in:
@@ -51,11 +51,22 @@ async function parseErrorBody(res: Response): Promise<string> {
|
||||
return `HTTP ${res.status} ${res.statusText}`.trim();
|
||||
}
|
||||
|
||||
/** Default timeout for every API request — hung streams must not spin forever. */
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Combine the caller's abort signal (if any) with the default timeout. */
|
||||
function requestSignal(signal?: AbortSignal | null): AbortSignal {
|
||||
const signals = [signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter(
|
||||
(s): s is AbortSignal => s != null,
|
||||
);
|
||||
return AbortSignal.any(signals);
|
||||
}
|
||||
|
||||
/** Fetch JSON from the API, throwing ApiError on non-2xx. */
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, init);
|
||||
res = await fetch(path, { ...init, signal: requestSignal(init?.signal) });
|
||||
} catch (err) {
|
||||
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||
}
|
||||
@@ -69,7 +80,7 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
|
||||
export async function apiFetchBlob(path: string): Promise<Blob> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path);
|
||||
res = await fetch(path, { signal: requestSignal() });
|
||||
} catch (err) {
|
||||
throw new ApiError(0, err instanceof Error ? err.message : "network error");
|
||||
}
|
||||
|
||||
@@ -244,7 +244,8 @@ export const manifestFileEntrySchema = z
|
||||
export const revokedCertEntrySchema = z
|
||||
.object({
|
||||
serialNumberHex: z.string().nullish(),
|
||||
serialNumber: z.string().nullish(),
|
||||
// Backend serializes CRL serials as a JSON number when they fit, else a string.
|
||||
serialNumber: z.union([z.string(), z.number()]).nullish(),
|
||||
revocationDate: z.string().nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -76,7 +76,8 @@ async function getList<S extends z.ZodTypeAny>(
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function getServiceInfo(): Promise<ServiceInfo> {
|
||||
return getData(`${API}`, serviceInfoSchema);
|
||||
// Trailing slash: the axum router serves the info document at `/api/v1/`.
|
||||
return getData(`${API}/`, serviceInfoSchema);
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<HealthStatus> {
|
||||
|
||||
@@ -86,6 +86,19 @@ export function DataTable<T>({
|
||||
key={rowKey(row)}
|
||||
className={onRowClick ? "clickable" : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
// Clickable rows behave like links: focusable and Enter/Space-activated.
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
role={onRowClick ? "link" : undefined}
|
||||
onKeyDown={
|
||||
onRowClick
|
||||
? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onRowClick(row);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
|
||||
@@ -14,16 +14,48 @@ import { StatusPill } from "./StatusPill";
|
||||
export interface ObjectFilterValues {
|
||||
type: string;
|
||||
result: string;
|
||||
reason: string;
|
||||
rejectedOnly: boolean;
|
||||
q: string;
|
||||
}
|
||||
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
|
||||
type: "",
|
||||
result: "",
|
||||
reason: "",
|
||||
rejectedOnly: false,
|
||||
q: "",
|
||||
};
|
||||
|
||||
/** Read object filters from the page URL (standalone pages sync them there). */
|
||||
export function objectFiltersFromParams(params: URLSearchParams): ObjectFilterValues {
|
||||
return {
|
||||
type: params.get("type") ?? "",
|
||||
result: params.get("result") ?? "",
|
||||
reason: params.get("reason") ?? "",
|
||||
rejectedOnly: params.get("rejected") === "true",
|
||||
q: params.get("q") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Write object filters into a URLSearchParams copy, preserving other params. */
|
||||
export function objectFiltersToParams(
|
||||
next: ObjectFilterValues,
|
||||
base: URLSearchParams,
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams(base);
|
||||
const write = (key: string, value: string) => {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
};
|
||||
write("type", next.type);
|
||||
write("result", next.result);
|
||||
write("reason", next.reason);
|
||||
write("q", next.q);
|
||||
if (next.rejectedOnly) params.set("rejected", "true");
|
||||
else params.delete("rejected");
|
||||
return params;
|
||||
}
|
||||
|
||||
/** 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"];
|
||||
@@ -55,7 +87,16 @@ export function ObjectsTable({
|
||||
useEffect(() => {
|
||||
setQDraft(filters.q);
|
||||
}, [filters.q]);
|
||||
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]);
|
||||
|
||||
// Auto-apply the URI draft after a short typing pause (Enter/blur still work).
|
||||
useEffect(() => {
|
||||
const trimmed = qDraft.trim();
|
||||
if (trimmed === filters.q) return;
|
||||
const timer = setTimeout(() => onFiltersChange({ ...filters, q: trimmed }), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [qDraft, filters, onFiltersChange]);
|
||||
|
||||
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.reason, filters.rejectedOnly, filters.q]);
|
||||
const pager = useCursorPager(filterKey);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -67,6 +108,7 @@ export function ObjectsTable({
|
||||
type: filters.type || undefined,
|
||||
result: filters.result || undefined,
|
||||
rejected: filters.rejectedOnly || undefined,
|
||||
reason: filters.reason || undefined,
|
||||
q: filters.q || undefined,
|
||||
}),
|
||||
placeholderData: (prev) => prev,
|
||||
@@ -101,7 +143,9 @@ export function ObjectsTable({
|
||||
{
|
||||
key: "result",
|
||||
header: "Result",
|
||||
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
|
||||
// Raw `result` value — the Result filter options must match what the
|
||||
// column displays (rejection is visible via the reason column/filter).
|
||||
render: (obj) => <StatusPill status={obj.result} />,
|
||||
},
|
||||
...(showReason
|
||||
? [
|
||||
@@ -162,6 +206,17 @@ export function ObjectsTable({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-field">
|
||||
<label htmlFor={`${queryKeyScope}-reason`}>Reason contains</label>
|
||||
<input
|
||||
id={`${queryKeyScope}-reason`}
|
||||
type="search"
|
||||
className="mono"
|
||||
value={filters.reason}
|
||||
onChange={(e) => onFiltersChange({ ...filters, reason: e.target.value })}
|
||||
placeholder="substring…"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-field">
|
||||
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
|
||||
<input
|
||||
@@ -174,7 +229,7 @@ export function ObjectsTable({
|
||||
if (e.key === "Enter") submitQ();
|
||||
}}
|
||||
onBlur={submitQ}
|
||||
placeholder="substring…"
|
||||
placeholder="substring… (applies as you type)"
|
||||
/>
|
||||
</div>
|
||||
<label className="filter-check">
|
||||
@@ -186,7 +241,7 @@ export function ObjectsTable({
|
||||
Rejected only
|
||||
</label>
|
||||
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
|
||||
server-side filters
|
||||
server-side filters · URI search applies as you type
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -182,12 +182,16 @@ function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstan
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [
|
||||
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | number | null; revocationDate?: string | null }>[] = [
|
||||
{
|
||||
key: "serial",
|
||||
header: "Serial number",
|
||||
render: (row) => (
|
||||
<CopyableValue value={row.serialNumberHex ?? row.serialNumber} max={44} label="serial number" />
|
||||
<CopyableValue
|
||||
value={row.serialNumberHex ?? (row.serialNumber != null ? String(row.serialNumber) : null)}
|
||||
max={44}
|
||||
label="serial number"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },
|
||||
|
||||
@@ -129,6 +129,9 @@ export function Shell({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
||||
<a href="#main-content" className="skip-link">
|
||||
Skip to content
|
||||
</a>
|
||||
<div className="shell-brand">
|
||||
<span className="brand-badge" aria-hidden="true">
|
||||
<ShieldCheck size={15} />
|
||||
@@ -170,7 +173,9 @@ export function Shell({ children }: { children: ReactNode }) {
|
||||
<div className="nav-footer">ours RP query layer</div>
|
||||
</nav>
|
||||
|
||||
<main className="shell-main">{children}</main>
|
||||
<main className="shell-main" id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ export function Tabs({
|
||||
role="tab"
|
||||
id={`tab-${tab.id}`}
|
||||
aria-selected={selected}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
// Only the active tab's panel is mounted — pointing aria-controls
|
||||
// at an unmounted panel would leave a dangling reference.
|
||||
aria-controls={selected ? `tabpanel-${tab.id}` : undefined}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => onChange(tab.id)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Cursor pagination state (previous-page stack) shared by all paged tables. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export interface CursorPager {
|
||||
/** Cursor to pass to the backend for the current page (null = first page). */
|
||||
@@ -40,33 +41,72 @@ export function pageNumber(state: PagerState): number {
|
||||
|
||||
/**
|
||||
* Cursor pager that resets whenever `resetKey` changes (run id, filters…).
|
||||
*
|
||||
* The current cursor lives in the `?cursor=` URL param so a refresh or a
|
||||
* shared link restores the current page; the previous-page stack stays in
|
||||
* component state (Prev works within one mounted session). Only one pager
|
||||
* is mounted per page, so a single shared param is unambiguous.
|
||||
*/
|
||||
export function useCursorPager(resetKey: string): CursorPager {
|
||||
const [state, setState] = useState<PagerState>(INITIAL_PAGER_STATE);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const cursor = searchParams.get("cursor");
|
||||
const [stack, setStack] = useState<(string | null)[]>([]);
|
||||
|
||||
const writeCursor = useCallback(
|
||||
(next: string | null) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
if (next === null) params.delete("cursor");
|
||||
else params.set("cursor", next);
|
||||
return params;
|
||||
},
|
||||
{ preventScrollReset: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// Reset on resetKey change — but not on first mount, so a refreshed or
|
||||
// deep-linked `?cursor=` survives.
|
||||
const lastResetKey = useRef(resetKey);
|
||||
useEffect(() => {
|
||||
setState(INITIAL_PAGER_STATE);
|
||||
}, [resetKey]);
|
||||
if (lastResetKey.current === resetKey) return;
|
||||
lastResetKey.current = resetKey;
|
||||
setStack([]);
|
||||
writeCursor(null);
|
||||
}, [resetKey, writeCursor]);
|
||||
|
||||
const goNext = useCallback((nextCursor: string | null) => {
|
||||
setState((s) => advancePager(s, nextCursor));
|
||||
}, []);
|
||||
const goNext = useCallback(
|
||||
(nextCursor: string | null) => {
|
||||
if (!nextCursor) return;
|
||||
setStack((s) => [...s, cursor]);
|
||||
writeCursor(nextCursor);
|
||||
},
|
||||
[cursor, writeCursor],
|
||||
);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setState((s) => retreatPager(s));
|
||||
}, []);
|
||||
if (stack.length === 0) return;
|
||||
const prev = stack[stack.length - 1];
|
||||
setStack(stack.slice(0, -1));
|
||||
writeCursor(prev);
|
||||
}, [stack, writeCursor]);
|
||||
|
||||
const reset = useCallback(() => setState(INITIAL_PAGER_STATE), []);
|
||||
const reset = useCallback(() => {
|
||||
setStack([]);
|
||||
writeCursor(null);
|
||||
}, [writeCursor]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
cursor: state.cursor,
|
||||
page: pageNumber(state),
|
||||
canPrev: state.stack.length > 0,
|
||||
cursor,
|
||||
page: stack.length + 1,
|
||||
canPrev: stack.length > 0,
|
||||
goNext,
|
||||
goPrev,
|
||||
reset,
|
||||
}),
|
||||
[state, goNext, goPrev, reset],
|
||||
[cursor, stack, goNext, goPrev, reset],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,14 @@ describe("formatPercent", () => {
|
||||
it("guards zero total", () => {
|
||||
expect(formatPercent(1, 0)).toBe("—");
|
||||
});
|
||||
it("marks tiny non-zero shares instead of rounding to 0.0%", () => {
|
||||
expect(formatPercent(1, 5000)).toBe("<0.1%");
|
||||
expect(formatPercent(0, 5000)).toBe("0.0%");
|
||||
});
|
||||
it("marks near-complete shares instead of rounding to 100.0%", () => {
|
||||
expect(formatPercent(4999, 5000)).toBe(">99.9%");
|
||||
expect(formatPercent(5000, 5000)).toBe("100.0%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateMiddle", () => {
|
||||
|
||||
@@ -70,7 +70,12 @@ export function formatRelative(value: string | null | undefined): string {
|
||||
|
||||
export function formatPercent(part: number, total: number): string {
|
||||
if (!total) return "—";
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
const pct = (part / total) * 100;
|
||||
// One decimal is misleading at the extremes: a non-zero share rounding to
|
||||
// "0.0%" looks empty, and 99.95% rounding to "100.0%" looks complete.
|
||||
if (pct > 0 && pct < 0.1) return "<0.1%";
|
||||
if (pct < 100 && pct > 99.9) return ">99.9%";
|
||||
return `${pct.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Truncate a long identifier for display, keeping head and tail. */
|
||||
|
||||
34
ui/rpki-explorer/src/lib/useExportJob.ts
Normal file
34
ui/rpki-explorer/src/lib/useExportJob.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shared "start export job + poll until finished" flow used by the object,
|
||||
* repository and publication point detail pages. The page renders the
|
||||
* returned mutation/query state with `WorkflowStatus` and friends.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createExport, getExportJob, type ExportRequest } from "../api/service";
|
||||
|
||||
export function useExportJob(runId: string, request: ExportRequest) {
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => createExport(runId, request),
|
||||
onSuccess: (job) => setJobId(job.jobId),
|
||||
});
|
||||
|
||||
const jobQuery = useQuery({
|
||||
queryKey: ["export-job", runId, jobId],
|
||||
queryFn: () => getExportJob(runId, jobId!),
|
||||
enabled: jobId !== null,
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
|
||||
// Keep the trigger disabled until the job reaches a terminal state so the
|
||||
// same export cannot be double-submitted while it is still running.
|
||||
const running =
|
||||
startMutation.isPending ||
|
||||
(jobId !== null &&
|
||||
jobQuery.data?.status !== "complete" &&
|
||||
jobQuery.data?.status !== "failed");
|
||||
|
||||
return { startMutation, jobQuery, running };
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/** Resolve the active run record for the current `?run=` context. */
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { ApiError } from "../api/client";
|
||||
import { getRun } from "../api/service";
|
||||
import type { RunRecord } from "../api/schemas";
|
||||
import { useRunId } from "./run";
|
||||
import { LATEST_RUN, useRunId } from "./run";
|
||||
|
||||
export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> } {
|
||||
const runId = useRunId();
|
||||
@@ -13,3 +14,16 @@ export function useRun(): { runId: string; runQuery: UseQueryResult<RunRecord> }
|
||||
});
|
||||
return { runId, runQuery };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-aware title for the run-scope error gate: a 404 means the requested
|
||||
* run does not exist; anything else is a service/transport failure.
|
||||
*/
|
||||
export function runErrorTitle(runId: string, error: unknown): string {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return runId === LATEST_RUN
|
||||
? "No indexed run available yet"
|
||||
: `Run ${runId} does not exist`;
|
||||
}
|
||||
return `Failed to load run ${runId}`;
|
||||
}
|
||||
|
||||
@@ -94,4 +94,34 @@ describe("classifyNetworkQuery", () => {
|
||||
expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" });
|
||||
expect(classifyNetworkQuery("")).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("reads two tokens as network + ASN in either order", () => {
|
||||
expect(classifyNetworkQuery("1.0.0.1 AS13335")).toEqual({
|
||||
kind: "ip",
|
||||
ip: "1.0.0.1",
|
||||
asn: 13335,
|
||||
});
|
||||
expect(classifyNetworkQuery("AS13335 1.0.0.1")).toEqual({
|
||||
kind: "ip",
|
||||
ip: "1.0.0.1",
|
||||
asn: 13335,
|
||||
});
|
||||
expect(classifyNetworkQuery("192.0.2.0/24 13335")).toEqual({
|
||||
kind: "prefix",
|
||||
prefix: "192.0.2.0/24",
|
||||
asn: 13335,
|
||||
});
|
||||
expect(classifyNetworkQuery(" 2001:db8::/32 as64496 ")).toEqual({
|
||||
kind: "prefix",
|
||||
prefix: "2001:db8::/32",
|
||||
asn: 64496,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multi-token queries without a network+ASN pair", () => {
|
||||
expect(classifyNetworkQuery("13335 64496")).toEqual({ kind: "none" });
|
||||
expect(classifyNetworkQuery("1.0.0.1 192.0.2.1")).toEqual({ kind: "none" });
|
||||
expect(classifyNetworkQuery("1.0.0.1 AS13335 extra")).toEqual({ kind: "none" });
|
||||
expect(classifyNetworkQuery("foo bar")).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
export type NetworkQuery =
|
||||
| { kind: "ip"; ip: string }
|
||||
| { kind: "prefix"; prefix: string }
|
||||
| { kind: "ip"; ip: string; asn?: number }
|
||||
| { kind: "prefix"; prefix: string; asn?: number }
|
||||
| { kind: "asn"; asn: number }
|
||||
| { kind: "none" };
|
||||
|
||||
@@ -90,10 +90,30 @@ export function parsePrefix(input: string): string | null {
|
||||
* Classify a query string. Precedence: CIDR prefix → bare IP → ASN → none.
|
||||
* ("192.0.2.1" also parses as digits+ dots only; ASN requires pure digits, so
|
||||
* no overlap. A bare number is always treated as an ASN.)
|
||||
*
|
||||
* Two whitespace-separated tokens are read as a combined query — one network
|
||||
* token (IP or prefix) plus one ASN token (`AS13335` or `13335`), in either
|
||||
* order — yielding an ip/prefix query carrying an `asn` filter.
|
||||
*/
|
||||
export function classifyNetworkQuery(input: string): NetworkQuery {
|
||||
const s = input.trim();
|
||||
if (!s) return { kind: "none" };
|
||||
const tokens = s.split(/\s+/);
|
||||
if (tokens.length === 2) {
|
||||
for (const [netToken, asnToken] of [
|
||||
[tokens[0], tokens[1]],
|
||||
[tokens[1], tokens[0]],
|
||||
] as const) {
|
||||
const asn = parseAsn(asnToken);
|
||||
if (asn === null) continue;
|
||||
const prefix = parsePrefix(netToken);
|
||||
if (prefix) return { kind: "prefix", prefix, asn };
|
||||
const ip = parseIpAddress(netToken);
|
||||
if (ip) return { kind: "ip", ip, asn };
|
||||
}
|
||||
return { kind: "none" };
|
||||
}
|
||||
if (tokens.length > 2) return { kind: "none" };
|
||||
const prefix = parsePrefix(s);
|
||||
if (prefix) return { kind: "prefix", prefix };
|
||||
const ip = parseIpAddress(s);
|
||||
|
||||
@@ -80,7 +80,6 @@ export default function ApiStatusPage() {
|
||||
<li>The query service has no CORS headers and no authentication — always front it with a same-origin proxy (this SPA) or an API gateway.</li>
|
||||
<li><code>/parsed</code>, <code>/raw</code> and exports require <code>--repo-bytes-db</code>.</li>
|
||||
<li>All list endpoints paginate with <code>limit</code> + <code>cursor</code>; the envelope is <code>{"{data, page, meta}"}</code>.</li>
|
||||
<li>VRP IP/prefix/ASN lookup is tracked as backend feature #070 and is not available yet.</li>
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@ import { DataTable, type Column } from "../components/DataTable";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { Notice } from "../components/StateBlock";
|
||||
import { ErrorBlock, Notice } from "../components/StateBlock";
|
||||
import { formatBytes, formatInt, formatRelative, formatUtc } from "../lib/format";
|
||||
import { useRun } from "../lib/useRun";
|
||||
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||
|
||||
/**
|
||||
* Export job history for the active run. Jobs are held in the query service
|
||||
* process memory, so the list resets when the service restarts.
|
||||
*/
|
||||
export default function ExportsPage() {
|
||||
const { runId } = useRun();
|
||||
const { runId, runQuery } = useRun();
|
||||
|
||||
const exportsQuery = useQuery({
|
||||
queryKey: ["exports", runId],
|
||||
@@ -25,6 +25,19 @@ export default function ExportsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
if (runQuery.isError) {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Exports" />
|
||||
<ErrorBlock
|
||||
error={runQuery.error}
|
||||
onRetry={() => runQuery.refetch()}
|
||||
title={runErrorTitle(runId, runQuery.error)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: Column<ExportJobRecord>[] = [
|
||||
{
|
||||
key: "job",
|
||||
|
||||
@@ -4,9 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Download, PackageOpen, Play, RefreshCw } from "lucide-react";
|
||||
import { apiFetchBlob, saveBlob } from "../api/client";
|
||||
import {
|
||||
createExport,
|
||||
explainObjectValidation,
|
||||
getExportJob,
|
||||
getObject,
|
||||
getObjectChain,
|
||||
getObjectProjection,
|
||||
@@ -23,8 +21,9 @@ import { StatusPill } from "../components/StatusPill";
|
||||
import { EmptyBlock, ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||
import { TabPanel, Tabs } from "../components/Tabs";
|
||||
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||
import { formatBytes, formatInt, objectTypeLabel } from "../lib/format";
|
||||
import { formatInt, objectTypeLabel } from "../lib/format";
|
||||
import { withRunParam } from "../lib/run";
|
||||
import { useExportJob } from "../lib/useExportJob";
|
||||
import { useRun } from "../lib/useRun";
|
||||
|
||||
function filenameFor(uri: string | undefined, sha256: string | undefined, type: string): string {
|
||||
@@ -207,7 +206,6 @@ export default function ObjectDetailPage() {
|
||||
const { runId } = useRun();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get("tab") ?? "parsed";
|
||||
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
||||
|
||||
const objectQuery = useQuery({
|
||||
queryKey: ["object", runId, objectInstanceId],
|
||||
@@ -229,17 +227,11 @@ export default function ObjectDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: () => createExport(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] }),
|
||||
onSuccess: (job) => setExportJobId(job.jobId),
|
||||
});
|
||||
|
||||
const exportJobQuery = useQuery({
|
||||
queryKey: ["export-job", runId, exportJobId],
|
||||
queryFn: () => getExportJob(runId, exportJobId!),
|
||||
enabled: exportJobId !== null,
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
const {
|
||||
startMutation: exportMutation,
|
||||
jobQuery: exportJobQuery,
|
||||
running: exportRunning,
|
||||
} = useExportJob(runId, { scope: "object_set", objectInstanceIds: [objectInstanceId] });
|
||||
|
||||
if (objectQuery.isError) {
|
||||
return (
|
||||
@@ -298,7 +290,7 @@ export default function ObjectDetailPage() {
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
disabled={exportMutation.isPending || exportJobQuery.data?.status === "running"}
|
||||
disabled={exportRunning}
|
||||
>
|
||||
{exportMutation.isPending ? <RefreshCw size={13} aria-hidden="true" /> : <PackageOpen size={13} aria-hidden="true" />}
|
||||
Export object set
|
||||
@@ -317,6 +309,12 @@ export default function ObjectDetailPage() {
|
||||
<Notice kind="error">Export failed to start: {exportMutation.error.message}</Notice>
|
||||
) : null}
|
||||
{exportJobQuery.data ? <WorkflowStatus job={exportJobQuery.data} runId={runId} /> : null}
|
||||
{exportJobQuery.data?.status === "complete" ? (
|
||||
<Notice kind="info">
|
||||
Export complete — {formatInt(exportJobQuery.data.objectCount)} objects. Find it any
|
||||
time on the <Link to={withRunParam("/exports", runId)}>Exports page</Link>.
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<Panel className="object-header-card">
|
||||
<dl className="meta-grid">
|
||||
@@ -334,8 +332,6 @@ export default function ObjectDetailPage() {
|
||||
<dd>{object.rejectReason ?? <span className="text-faint">—</span>}</dd>
|
||||
<dt>Detail</dt>
|
||||
<dd>{object.detailSummary ?? <span className="text-faint">—</span>}</dd>
|
||||
<dt>Size</dt>
|
||||
<dd>{formatBytes(object.sizeBytes)}</dd>
|
||||
<dt>Repository</dt>
|
||||
<dd>
|
||||
<Link className="mono" to={withRunParam(`/repositories/${encodeURIComponent(object.repoId)}`, runId)}>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { listObjects } from "../api/service";
|
||||
import { Notice } from "../components/StateBlock";
|
||||
import {
|
||||
EMPTY_OBJECT_FILTERS,
|
||||
objectFiltersFromParams,
|
||||
objectFiltersToParams,
|
||||
ObjectsTable,
|
||||
type ObjectFilterValues,
|
||||
} from "../components/ObjectsTable";
|
||||
@@ -11,37 +13,19 @@ import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { useRun } from "../lib/useRun";
|
||||
|
||||
function filtersFromParams(params: URLSearchParams): ObjectFilterValues {
|
||||
return {
|
||||
type: params.get("type") ?? "",
|
||||
result: params.get("result") ?? "",
|
||||
rejectedOnly: params.get("rejected") === "true",
|
||||
q: params.get("q") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function ObjectsPage() {
|
||||
const { runId } = useRun();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filters = useMemo(() => filtersFromParams(searchParams), [searchParams]);
|
||||
const filters = useMemo(() => objectFiltersFromParams(searchParams), [searchParams]);
|
||||
|
||||
const setFilters = (next: ObjectFilterValues) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const write = (key: string, value: string) => {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
};
|
||||
write("type", next.type);
|
||||
write("result", next.result);
|
||||
write("q", next.q);
|
||||
if (next.rejectedOnly) params.set("rejected", "true");
|
||||
else params.delete("rejected");
|
||||
setSearchParams(params, { preventScrollReset: true });
|
||||
setSearchParams(objectFiltersToParams(next, searchParams), { preventScrollReset: true });
|
||||
};
|
||||
|
||||
const hasFilters =
|
||||
filters.type !== EMPTY_OBJECT_FILTERS.type ||
|
||||
filters.result !== "" ||
|
||||
filters.reason !== "" ||
|
||||
filters.rejectedOnly ||
|
||||
filters.q !== "";
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
truncateMiddle,
|
||||
} from "../lib/format";
|
||||
import { withRunParam } from "../lib/run";
|
||||
import { useRun } from "../lib/useRun";
|
||||
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||
|
||||
/** Semantic colors keyed by validation result — never assigned by index. */
|
||||
const RESULT_COLORS: Record<string, string> = {
|
||||
@@ -126,7 +126,7 @@ export default function OverviewPage() {
|
||||
<ErrorBlock
|
||||
error={runQuery.error}
|
||||
onRetry={() => runQuery.refetch()}
|
||||
title="Failed to load the latest run"
|
||||
title={runErrorTitle(runId, runQuery.error)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -170,6 +170,17 @@ export default function OverviewPage() {
|
||||
numeric: true,
|
||||
render: (repo) => formatDurationMs(repo.syncDurationMsTotal),
|
||||
},
|
||||
{
|
||||
key: "terminal",
|
||||
header: "Terminal states",
|
||||
render: (repo) => (
|
||||
<span className="chip-list">
|
||||
{Object.entries(repo.terminalStates ?? {}).map(([state, count]) => (
|
||||
<StatusPill key={state} status={state} label={`${state} ${formatInt(count)}`} />
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -224,6 +235,7 @@ export default function OverviewPage() {
|
||||
value={formatInt(counts?.warnings)}
|
||||
tone="amber"
|
||||
sub="objects with warnings"
|
||||
to={withRunParam("/validation", runId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +250,7 @@ export default function OverviewPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="chart-box">
|
||||
<ResponsiveContainer>
|
||||
<ResponsiveContainer initialDimension={{ width: 400, height: 240 }}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={validationData}
|
||||
@@ -286,7 +298,7 @@ export default function OverviewPage() {
|
||||
<p className="text-muted">No object type stats recorded.</p>
|
||||
) : (
|
||||
<div className="chart-box">
|
||||
<ResponsiveContainer>
|
||||
<ResponsiveContainer initialDimension={{ width: 400, height: 240 }}>
|
||||
<BarChart data={typeData} layout="vertical" margin={{ left: 12, right: 24 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { PackageOpen, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
getPublicationPoint,
|
||||
listPublicationPointObjects,
|
||||
@@ -8,27 +9,37 @@ import {
|
||||
import { CopyableValue } from "../components/CopyableValue";
|
||||
import { KpiCard } from "../components/KpiCard";
|
||||
import {
|
||||
EMPTY_OBJECT_FILTERS,
|
||||
objectFiltersFromParams,
|
||||
objectFiltersToParams,
|
||||
ObjectsTable,
|
||||
type ObjectFilterValues,
|
||||
} from "../components/ObjectsTable";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
|
||||
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||
import { ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||
import { formatDurationMs, formatInt, formatUtc } from "../lib/format";
|
||||
import { withRunParam } from "../lib/run";
|
||||
import { useExportJob } from "../lib/useExportJob";
|
||||
import { useRun } from "../lib/useRun";
|
||||
|
||||
export default function PublicationPointDetailPage() {
|
||||
const { ppId = "" } = useParams();
|
||||
const { runId } = useRun();
|
||||
const [objectFilters, setObjectFilters] = useState<ObjectFilterValues>(EMPTY_OBJECT_FILTERS);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Object filters live in the URL so they survive reloads and stay shareable.
|
||||
const objectFilters = useMemo(() => objectFiltersFromParams(searchParams), [searchParams]);
|
||||
|
||||
const setObjectFilters = (next: ObjectFilterValues) => {
|
||||
setSearchParams(objectFiltersToParams(next, searchParams), { preventScrollReset: true });
|
||||
};
|
||||
|
||||
const ppQuery = useQuery({
|
||||
queryKey: ["pp", runId, ppId],
|
||||
queryFn: () => getPublicationPoint(runId, ppId),
|
||||
});
|
||||
const exportJob = useExportJob(runId, { scope: "publication_point", ppId });
|
||||
|
||||
if (ppQuery.isError) {
|
||||
return (
|
||||
@@ -60,9 +71,31 @@ export default function PublicationPointDetailPage() {
|
||||
<PageHeader
|
||||
title={<span className="mono" style={{ fontSize: 16 }}>{pp.manifestRsyncUri ?? pp.ppId}</span>}
|
||||
subtitle={pp.repoSyncError ? `Sync error: ${pp.repoSyncError}` : undefined}
|
||||
actions={<StatusPill status={pp.repoTerminalState} label={`terminal ${pp.repoTerminalState ?? "unknown"}`} />}
|
||||
actions={
|
||||
<>
|
||||
<StatusPill status={pp.repoTerminalState} label={`terminal ${pp.repoTerminalState ?? "unknown"}`} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => exportJob.startMutation.mutate()}
|
||||
disabled={exportJob.running}
|
||||
>
|
||||
{exportJob.startMutation.isPending ? (
|
||||
<RefreshCw size={13} aria-hidden="true" />
|
||||
) : (
|
||||
<PackageOpen size={13} aria-hidden="true" />
|
||||
)}
|
||||
Export
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{exportJob.startMutation.isError ? (
|
||||
<Notice kind="error">Export failed to start: {exportJob.startMutation.error.message}</Notice>
|
||||
) : null}
|
||||
{exportJob.jobQuery.data ? <WorkflowStatus job={exportJob.jobQuery.data} runId={runId} /> : null}
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard label="Objects" value={formatInt(pp.objects)} />
|
||||
<KpiCard label="Rejected" value={formatInt(pp.rejectedObjects)} tone="red" />
|
||||
|
||||
@@ -104,7 +104,8 @@ export default function PublicationPointsPage() {
|
||||
loading={ppsQuery.isPending}
|
||||
error={ppsQuery.isError ? ppsQuery.error : undefined}
|
||||
onRetry={() => ppsQuery.refetch()}
|
||||
emptyTitle="No publication points indexed"
|
||||
emptyTitle={needle.trim() ? "No matches on this page — clear the filter" : "No publication points indexed"}
|
||||
emptyHint={needle.trim() ? "The filter only narrows the rows of the current page." : undefined}
|
||||
caption="Publication points"
|
||||
onRowClick={(pp) =>
|
||||
navigate(withRunParam(`/publication-points/${encodeURIComponent(pp.ppId)}`, runId))
|
||||
|
||||
@@ -122,8 +122,12 @@ export default function RepositoriesPage() {
|
||||
loading={reposQuery.isPending}
|
||||
error={reposQuery.isError ? reposQuery.error : undefined}
|
||||
onRetry={() => reposQuery.refetch()}
|
||||
emptyTitle="No repositories indexed"
|
||||
emptyHint="The query service has not indexed any run yet."
|
||||
emptyTitle={needle.trim() ? "No matches on this page — clear the filter" : "No repositories indexed"}
|
||||
emptyHint={
|
||||
needle.trim()
|
||||
? "The filter only narrows the rows of the current page."
|
||||
: "The query service has not indexed any run yet."
|
||||
}
|
||||
caption="Repositories"
|
||||
onRowClick={(repo) =>
|
||||
navigate(withRunParam(`/repositories/${encodeURIComponent(repo.repoId)}`, runId))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { PackageOpen, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
getRepo,
|
||||
getRepoStats,
|
||||
@@ -13,7 +14,8 @@ import { CursorPagerControls } from "../components/CursorPagerControls";
|
||||
import { DataTable, type Column } from "../components/DataTable";
|
||||
import { KpiCard } from "../components/KpiCard";
|
||||
import {
|
||||
EMPTY_OBJECT_FILTERS,
|
||||
objectFiltersFromParams,
|
||||
objectFiltersToParams,
|
||||
ObjectsTable,
|
||||
type ObjectFilterValues,
|
||||
} from "../components/ObjectsTable";
|
||||
@@ -21,10 +23,12 @@ import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
import { TabPanel, Tabs } from "../components/Tabs";
|
||||
import { ErrorBlock, LoadingBlock } from "../components/StateBlock";
|
||||
import { WorkflowStatus } from "../components/WorkflowStatus";
|
||||
import { ErrorBlock, LoadingBlock, Notice } from "../components/StateBlock";
|
||||
import { useCursorPager } from "../lib/cursor";
|
||||
import { formatDurationMs, formatInt, formatUtc, truncateMiddle } from "../lib/format";
|
||||
import { withRunParam } from "../lib/run";
|
||||
import { useExportJob } from "../lib/useExportJob";
|
||||
import { useRun } from "../lib/useRun";
|
||||
|
||||
function RepoPpsTable({ runId, repoId }: { runId: string; repoId: string }) {
|
||||
@@ -109,7 +113,12 @@ export default function RepositoryDetailPage() {
|
||||
const { runId, runQuery } = useRun();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get("tab") ?? "pps";
|
||||
const [objectFilters, setObjectFilters] = useState<ObjectFilterValues>(EMPTY_OBJECT_FILTERS);
|
||||
// Object filters live in the URL so they survive reloads and stay shareable.
|
||||
const objectFilters = useMemo(() => objectFiltersFromParams(searchParams), [searchParams]);
|
||||
|
||||
const setObjectFilters = (next: ObjectFilterValues) => {
|
||||
setSearchParams(objectFiltersToParams(next, searchParams), { preventScrollReset: true });
|
||||
};
|
||||
|
||||
const repoQuery = useQuery({
|
||||
queryKey: ["repo", runId, repoId],
|
||||
@@ -119,6 +128,7 @@ export default function RepositoryDetailPage() {
|
||||
queryKey: ["repo-stats", runId, repoId],
|
||||
queryFn: () => getRepoStats(runId, repoId),
|
||||
});
|
||||
const exportJob = useExportJob(runId, { scope: "repo", repoId });
|
||||
|
||||
if (repoQuery.isError) {
|
||||
return (
|
||||
@@ -157,9 +167,31 @@ export default function RepositoryDetailPage() {
|
||||
<PageHeader
|
||||
title={repo.host}
|
||||
subtitle={<CopyableValue value={repo.uri} max={96} label="repository URI" />}
|
||||
actions={<StatusPill status={runQuery.data?.indexStatus} label={`run ${runQuery.data?.runId ?? runId}`} />}
|
||||
actions={
|
||||
<>
|
||||
<StatusPill status={runQuery.data?.indexStatus} label={`run ${runQuery.data?.runId ?? runId}`} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => exportJob.startMutation.mutate()}
|
||||
disabled={exportJob.running}
|
||||
>
|
||||
{exportJob.startMutation.isPending ? (
|
||||
<RefreshCw size={13} aria-hidden="true" />
|
||||
) : (
|
||||
<PackageOpen size={13} aria-hidden="true" />
|
||||
)}
|
||||
Export
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{exportJob.startMutation.isError ? (
|
||||
<Notice kind="error">Export failed to start: {exportJob.startMutation.error.message}</Notice>
|
||||
) : null}
|
||||
{exportJob.jobQuery.data ? <WorkflowStatus job={exportJob.jobQuery.data} runId={runId} /> : null}
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard label="Publication points" value={formatInt(repo.publicationPoints)} />
|
||||
<KpiCard label="Objects" value={formatInt(repo.objects)} />
|
||||
|
||||
@@ -95,6 +95,7 @@ export default function RunsPage() {
|
||||
className="btn small"
|
||||
disabled={active}
|
||||
onClick={() => navigate(`/?run=${encodeURIComponent(run.runId)}`)}
|
||||
aria-label={active ? `Run ${run.runId} is active` : `Use run ${run.runId}`}
|
||||
>
|
||||
{active ? "Active" : "Use run"}
|
||||
</button>
|
||||
@@ -103,6 +104,7 @@ export default function RunsPage() {
|
||||
className="btn small"
|
||||
onClick={() => setExpandedRun((prev) => (prev === run.runId ? null : run.runId))}
|
||||
aria-expanded={expandedRun === run.runId}
|
||||
aria-label={`Show artifacts for run ${run.runId}`}
|
||||
>
|
||||
Artifacts
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock";
|
||||
import { useCursorPager } from "../lib/cursor";
|
||||
import { objectTypeLabel } from "../lib/format";
|
||||
import { withRunParam } from "../lib/run";
|
||||
import { useRun } from "../lib/useRun";
|
||||
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||
import { classifyNetworkQuery, parseAsn, type NetworkQuery } from "../lib/vrpQuery";
|
||||
|
||||
const VRP_PAGE_SIZE = 50;
|
||||
@@ -139,7 +139,7 @@ function VrpResults({ network }: { network: NetworkQuery & { kind: "ip" | "prefi
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const { runId } = useRun();
|
||||
const { runId, runQuery } = useRun();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const [draft, setDraft] = useState(q);
|
||||
@@ -149,6 +149,18 @@ export default function SearchPage() {
|
||||
const network: NetworkQuery = useMemo(() => classifyNetworkQuery(q), [q]);
|
||||
const isVrpLookup = network.kind === "ip" || network.kind === "prefix";
|
||||
|
||||
// A combined query ("1.0.0.1 AS13335") carries its own ASN — prefill the
|
||||
// ?asn= filter so the VRP results apply it (the user can still override).
|
||||
useEffect(() => {
|
||||
if (isVrpLookup && network.asn !== undefined) {
|
||||
if (searchParams.get("asn") !== String(network.asn)) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("asn", String(network.asn));
|
||||
setSearchParams(params, { replace: true, preventScrollReset: true });
|
||||
}
|
||||
}
|
||||
}, [isVrpLookup, network, searchParams, setSearchParams]);
|
||||
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ["search", runId, q],
|
||||
queryFn: () => searchRun(runId, q, 10),
|
||||
@@ -164,6 +176,19 @@ export default function SearchPage() {
|
||||
setSearchParams(params, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
if (runQuery.isError) {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Search" />
|
||||
<ErrorBlock
|
||||
error={runQuery.error}
|
||||
onRetry={() => runQuery.refetch()}
|
||||
title={runErrorTitle(runId, runQuery.error)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const result = searchQuery.data;
|
||||
const totalHits =
|
||||
(result?.objects.length ?? 0) +
|
||||
@@ -287,6 +312,7 @@ export default function SearchPage() {
|
||||
<li><code>rsync://</code> / <code>https://</code> URI — exact object match</li>
|
||||
<li>64 hex chars — object by SHA-256; 8+ hex chars — SHA-256 prefix scan</li>
|
||||
<li>IPv4 / IPv6 address or CIDR prefix — VRP lookup (covering VRPs), with optional ASN filter</li>
|
||||
<li>IP / prefix + ASN in one query (<code>1.0.0.1 AS13335</code>) — prefills the VRP ASN filter</li>
|
||||
<li><code>AS</code> number alone — not supported by the VRP API; combine with an IP or prefix</li>
|
||||
<li>any other text — substring match on repository host/URI and publication point URIs</li>
|
||||
</ul>
|
||||
|
||||
@@ -19,11 +19,11 @@ import type { ObjectInstanceRecord } from "../api/schemas";
|
||||
import { useCursorPager } from "../lib/cursor";
|
||||
import { formatInt, formatPercent, objectTypeLabel, truncateMiddle } from "../lib/format";
|
||||
import { withRunParam } from "../lib/run";
|
||||
import { useRun } from "../lib/useRun";
|
||||
import { useRun, runErrorTitle } from "../lib/useRun";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function ValidationPage() {
|
||||
const { runId } = useRun();
|
||||
const { runId, runQuery } = useRun();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const reason = searchParams.get("reason") ?? "";
|
||||
@@ -88,6 +88,19 @@ export default function ValidationPage() {
|
||||
setSearchParams(params, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
if (runQuery.isError) {
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Validation" />
|
||||
<ErrorBlock
|
||||
error={runQuery.error}
|
||||
onRetry={() => runQuery.refetch()}
|
||||
title={runErrorTitle(runId, runQuery.error)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: Column<ObjectInstanceRecord>[] = [
|
||||
{
|
||||
key: "type",
|
||||
|
||||
@@ -149,6 +149,19 @@
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: 0 0 var(--radius-l) var(--radius-l);
|
||||
/* Scroll shadows hint at horizontally clipped columns (narrow viewports). */
|
||||
background:
|
||||
linear-gradient(to right, var(--bg-1) 30%, transparent),
|
||||
linear-gradient(to left, var(--bg-1) 30%, transparent),
|
||||
radial-gradient(farthest-side at 0 50%, rgba(15, 23, 42, 0.14), transparent),
|
||||
radial-gradient(farthest-side at 100% 50%, rgba(15, 23, 42, 0.14), transparent);
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
40px 100%,
|
||||
40px 100%,
|
||||
12px 100%,
|
||||
12px 100%;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
table.data-table {
|
||||
|
||||
@@ -13,6 +13,36 @@
|
||||
grid-template-columns: var(--sidebar-width-collapsed) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut into the main content — hidden until focused. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link:focus-visible {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
top: var(--space-3);
|
||||
left: var(--space-3);
|
||||
z-index: 100;
|
||||
background: var(--blue-600);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-m);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.shell-brand {
|
||||
grid-area: brand;
|
||||
display: flex;
|
||||
@@ -294,6 +324,19 @@
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: var(--space-2);
|
||||
/* Edge fades hint that the nav scrolls horizontally. */
|
||||
background:
|
||||
linear-gradient(to right, var(--text-1) 30%, transparent),
|
||||
linear-gradient(to left, var(--text-1) 30%, transparent),
|
||||
radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.4), transparent),
|
||||
radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.4), transparent);
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
32px 100%,
|
||||
32px 100%,
|
||||
10px 100%,
|
||||
10px 100%;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
.shell-nav .nav-toggle,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
--text-1: #0f172a;
|
||||
--text-2: #475569;
|
||||
--text-3: #8a97a8;
|
||||
--text-3: #64748b;
|
||||
|
||||
--bg-0: #f4f6fa;
|
||||
--bg-1: #ffffff;
|
||||
|
||||
Reference in New Issue
Block a user