20260718 Explorer 接入 VRP 检索查询
This commit is contained in:
@@ -22,6 +22,7 @@ const ENDPOINTS: { method: "GET" | "POST"; path: string; note: string }[] = [
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/objects/{id}/raw", note: "Raw DER bytes (needs repo-bytes-db)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/issues", note: "Rejected/errored objects (filters: reason,type,repoId,ppId)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/search?q=", note: "Unified search: URI / sha256 / repo host / PP URI" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/vrps/lookup?ip=|prefix=[&asn=]", note: "VRP lookup (covering semantics, cursor paged)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/stats/{validation,object-types,reasons}", note: "Aggregated counters" },
|
||||
{ method: "POST", path: "/api/v1/runs/{run}/exports", note: "Start export job (repo / publication_point / object_set)" },
|
||||
{ method: "GET", path: "/api/v1/runs/{run}/exports", note: "Export job list (process memory)" },
|
||||
|
||||
@@ -1,22 +1,140 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileBox, FolderTree, Server } from "lucide-react";
|
||||
import { searchRun } from "../api/service";
|
||||
import { lookupVrps, searchRun } from "../api/service";
|
||||
import type { VrpRecord } from "../api/schemas";
|
||||
import { CursorPagerControls } from "../components/CursorPagerControls";
|
||||
import { DataTable, type Column } from "../components/DataTable";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { StatusPill } from "../components/StatusPill";
|
||||
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 { classifyNetworkQuery, parseAsn, type NetworkQuery } from "../lib/vrpQuery";
|
||||
|
||||
const VRP_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* VRP lookup results for IP / CIDR queries, backed by
|
||||
* `GET /runs/{id}/vrps/lookup` (backend feature #122). Optional ASN filter
|
||||
* rides in the `?asn=` URL param so results stay deep-linkable.
|
||||
*/
|
||||
function VrpResults({ network }: { network: NetworkQuery & { kind: "ip" | "prefix" } }) {
|
||||
const { runId } = useRun();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const asnParam = searchParams.get("asn") ?? "";
|
||||
const asn = asnParam ? parseAsn(asnParam) : null;
|
||||
const [asnDraft, setAsnDraft] = useState(asnParam);
|
||||
const [asnError, setAsnError] = useState<string | null>(null);
|
||||
const pager = useCursorPager(`vrp|${runId}|${network.kind}|${network.kind === "ip" ? network.ip : network.prefix}|${asn ?? ""}`);
|
||||
|
||||
useEffect(() => setAsnDraft(asnParam), [asnParam]);
|
||||
|
||||
const target = network.kind === "ip" ? { ip: network.ip } : { prefix: network.prefix };
|
||||
const vrpQuery = useQuery({
|
||||
queryKey: ["vrp-lookup", runId, target, asn, pager.cursor],
|
||||
queryFn: () =>
|
||||
lookupVrps(runId, { ...target, asn: asn ?? undefined, limit: VRP_PAGE_SIZE, cursor: pager.cursor }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const applyAsnFilter = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const draft = asnDraft.trim();
|
||||
if (!draft) {
|
||||
setAsnError(null);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.delete("asn");
|
||||
setSearchParams(params, { preventScrollReset: true });
|
||||
return;
|
||||
}
|
||||
const parsed = parseAsn(draft);
|
||||
if (parsed === null) {
|
||||
setAsnError(`“${draft}” is not a valid ASN (digits, optionally AS-prefixed).`);
|
||||
return;
|
||||
}
|
||||
setAsnError(null);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("asn", String(parsed));
|
||||
setSearchParams(params, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
const columns: Column<VrpRecord>[] = [
|
||||
{
|
||||
key: "asn",
|
||||
header: "ASN",
|
||||
render: (vrp) => <span className="cell-main mono">AS{vrp.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "prefix",
|
||||
header: "Prefix",
|
||||
render: (vrp) => <span className="mono">{vrp.prefix}</span>,
|
||||
},
|
||||
{
|
||||
key: "maxLength",
|
||||
header: "Max length",
|
||||
numeric: true,
|
||||
render: (vrp) => <span className="mono">/{vrp.maxLength}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const targetLabel = network.kind === "ip" ? network.ip : network.prefix;
|
||||
const emptyHint =
|
||||
network.kind === "prefix"
|
||||
? "Covering semantics: a VRP only matches when its prefix covers the whole queried prefix and its maxLength is at least the queried length."
|
||||
: "No VRP prefix covers this address in the active run.";
|
||||
|
||||
/** Detect queries the backend cannot serve yet (VRP lookup = backlog #070). */
|
||||
function looksLikeNetworkQuery(q: string): boolean {
|
||||
return (
|
||||
/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(q) || // IPv4 / prefix
|
||||
/^[0-9a-fA-F:]*:[0-9a-fA-F:.]*(\/\d{1,3})?$/.test(q) || // IPv6-ish
|
||||
/^(AS)?\d{1,10}$/i.test(q) // ASN
|
||||
<Panel
|
||||
title="VRP lookup"
|
||||
subtitle={
|
||||
network.kind === "ip"
|
||||
? `VRPs whose prefix covers ${targetLabel}`
|
||||
: `VRPs covering the whole prefix ${targetLabel} (maxLength ≥ queried length)`
|
||||
}
|
||||
tools={
|
||||
<form className="vrp-asn-filter" onSubmit={applyAsnFilter} role="search">
|
||||
<label htmlFor="vrp-asn">ASN filter</label>
|
||||
<input
|
||||
id="vrp-asn"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={asnDraft}
|
||||
onChange={(e) => setAsnDraft(e.target.value)}
|
||||
placeholder="e.g. AS45090 (optional)"
|
||||
/>
|
||||
<button type="submit" className="btn small">
|
||||
Apply
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
{asnError ? <Notice kind="warning">{asnError}</Notice> : null}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={vrpQuery.data?.items ?? []}
|
||||
rowKey={(vrp) => `${vrp.asn}-${vrp.prefix}-${vrp.maxLength}`}
|
||||
loading={vrpQuery.isPending}
|
||||
error={vrpQuery.isError ? vrpQuery.error : undefined}
|
||||
onRetry={() => vrpQuery.refetch()}
|
||||
emptyTitle={`No VRPs cover ${targetLabel}${asn !== null ? ` for AS${asn}` : ""}`}
|
||||
emptyHint={emptyHint}
|
||||
caption={`VRP lookup results for ${targetLabel}`}
|
||||
/>
|
||||
<CursorPagerControls
|
||||
page={pager.page}
|
||||
canPrev={pager.canPrev}
|
||||
hasNext={Boolean(vrpQuery.data?.nextCursor)}
|
||||
loading={vrpQuery.isFetching}
|
||||
onPrev={pager.goPrev}
|
||||
onNext={() => pager.goNext(vrpQuery.data?.nextCursor ?? null)}
|
||||
itemCount={vrpQuery.data?.items.length}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,10 +146,13 @@ export default function SearchPage() {
|
||||
|
||||
useEffect(() => setDraft(q), [q]);
|
||||
|
||||
const network: NetworkQuery = useMemo(() => classifyNetworkQuery(q), [q]);
|
||||
const isVrpLookup = network.kind === "ip" || network.kind === "prefix";
|
||||
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ["search", runId, q],
|
||||
queryFn: () => searchRun(runId, q, 10),
|
||||
enabled: q.trim().length > 0,
|
||||
enabled: !isVrpLookup && q.trim().length > 0,
|
||||
});
|
||||
|
||||
const onSubmit = (event: FormEvent) => {
|
||||
@@ -39,6 +160,7 @@ export default function SearchPage() {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (draft.trim()) params.set("q", draft.trim());
|
||||
else params.delete("q");
|
||||
params.delete("asn");
|
||||
setSearchParams(params, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
@@ -52,7 +174,7 @@ export default function SearchPage() {
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Search"
|
||||
subtitle="Exact object URI, SHA-256 (full or ≥8 hex prefix), repository host, or publication point URI"
|
||||
subtitle="Object URI, SHA-256, repository host, publication point URI — or an IP / prefix for VRP lookup"
|
||||
/>
|
||||
|
||||
<form className="search-hero" role="search" onSubmit={onSubmit}>
|
||||
@@ -60,7 +182,7 @@ export default function SearchPage() {
|
||||
type="search"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="rsync://… or 64-char sha256 or repository host…"
|
||||
placeholder="rsync://… · sha256… · 81.68.1.1 · 2001:db8::/32 · AS45090"
|
||||
aria-label="Search query"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -69,19 +191,21 @@ export default function SearchPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{q && looksLikeNetworkQuery(q) ? (
|
||||
{network.kind === "asn" ? (
|
||||
<Notice kind="info">
|
||||
IP prefix and ASN lookup of VRPs requires backend feature #070 (VRP lookup),
|
||||
which is not implemented yet. The results below only cover objects,
|
||||
repositories and publication points.
|
||||
The VRP API cannot list VRPs by ASN alone — query an IP address or a
|
||||
CIDR prefix first, then narrow the hits with the ASN filter. The
|
||||
results below only cover objects, repositories and publication points.
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
{!q ? (
|
||||
<EmptyBlock
|
||||
title="Enter a query"
|
||||
hint="Paste an object URI to jump straight to its detail page, or a hash / host to find matching entities."
|
||||
hint="Paste an object URI to jump straight to its detail page, an IP / prefix for a VRP lookup, or a hash / host to find matching entities."
|
||||
/>
|
||||
) : isVrpLookup ? (
|
||||
<VrpResults network={network} />
|
||||
) : searchQuery.isError ? (
|
||||
<ErrorBlock error={searchQuery.error} onRetry={() => searchQuery.refetch()} title="Search failed" />
|
||||
) : searchQuery.isPending ? (
|
||||
@@ -162,8 +286,9 @@ export default function SearchPage() {
|
||||
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<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><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>
|
||||
<li>IP / prefix / ASN VRP lookup is planned under backend feature #070</li>
|
||||
</ul>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user