20260718 Explorer 接入 VRP 检索查询

This commit is contained in:
2026-07-20 10:05:00 +08:00
parent 6d9eedd680
commit 06f4b086b4
12 changed files with 554 additions and 22 deletions

View File

@@ -209,6 +209,15 @@ export const searchResultSchema = z
})
.passthrough();
export const vrpRecordSchema = z
.object({
runId: z.string(),
asn: z.number(),
prefix: z.string(),
maxLength: z.number(),
})
.passthrough();
export const healthSchema = z
.object({
status: z.string(),
@@ -295,6 +304,7 @@ export type ValidationStage = z.infer<typeof validationStageSchema>;
export type ValidationSummary = z.infer<typeof validationSummarySchema>;
export type ExplainRecord = z.infer<typeof explainRecordSchema>;
export type SearchResult = z.infer<typeof searchResultSchema>;
export type VrpRecord = z.infer<typeof vrpRecordSchema>;
export type HealthStatus = z.infer<typeof healthSchema>;
export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
export type ManifestFileEntry = z.infer<typeof manifestFileEntrySchema>;

View File

@@ -22,6 +22,7 @@ import {
serviceInfoSchema,
statsMapSchema,
validationSummarySchema,
vrpRecordSchema,
type ChainEdgeRecord,
type ExportJobRecord,
type ExplainRecord,
@@ -38,6 +39,7 @@ import {
type SearchResult,
type ServiceInfo,
type ValidationSummary,
type VrpRecord,
} from "./schemas";
const API = "/api/v1";
@@ -387,6 +389,26 @@ export function searchRun(
return getData(`${runBase(runId)}/search`, searchResultSchema, { q, limit });
}
/* ------------------------------------------------------------------ */
/* VRP lookup */
/* ------------------------------------------------------------------ */
export type VrpLookupParams = PageParams & {
/** Exact IPv4/IPv6 address (mutually exclusive with `prefix`). */
ip?: string;
/** CIDR; returns VRPs covering the whole prefix (maxLength semantics). */
prefix?: string;
/** Optional ASN filter applied on top of ip/prefix. */
asn?: number;
};
export function lookupVrps(
runId: string,
params: VrpLookupParams,
): Promise<ListResult<VrpRecord>> {
return getList(`${runBase(runId)}/vrps/lookup`, vrpRecordSchema, params);
}
/* ------------------------------------------------------------------ */
/* Stats */
/* ------------------------------------------------------------------ */

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
classifyNetworkQuery,
parseAsn,
parseIpAddress,
parsePrefix,
} from "./vrpQuery";
describe("parseAsn", () => {
it("accepts bare and AS-prefixed numbers", () => {
expect(parseAsn("45090")).toBe(45090);
expect(parseAsn("AS45090")).toBe(45090);
expect(parseAsn("as0")).toBe(0);
expect(parseAsn(" AS64496 ")).toBe(64496);
});
it("rejects out-of-range and malformed ASNs", () => {
expect(parseAsn("4294967295")).toBe(4294967295);
expect(parseAsn("4294967296")).toBeNull();
expect(parseAsn("AS123abc")).toBeNull();
expect(parseAsn("AS")).toBeNull();
expect(parseAsn("-1")).toBeNull();
});
});
describe("parseIpAddress", () => {
it("accepts IPv4 addresses", () => {
expect(parseIpAddress("81.68.1.1")).toBe("81.68.1.1");
expect(parseIpAddress("0.0.0.0")).toBe("0.0.0.0");
expect(parseIpAddress("255.255.255.255")).toBe("255.255.255.255");
});
it("rejects IPv4 with bad octets or CIDR suffix", () => {
expect(parseIpAddress("256.0.0.1")).toBeNull();
expect(parseIpAddress("192.0.2.1/24")).toBeNull();
expect(parseIpAddress("192.0.2")).toBeNull();
});
it("accepts IPv6 addresses", () => {
expect(parseIpAddress("2001:7fa:0:1::1")).toBe("2001:7fa:0:1::1");
expect(parseIpAddress("::")).toBe("::");
expect(parseIpAddress("::1")).toBe("::1");
expect(parseIpAddress("2001:db8:0:0:0:0:0:1")).toBe("2001:db8:0:0:0:0:0:1");
expect(parseIpAddress("1:2:3:4:5:6:7::")).toBe("1:2:3:4:5:6:7::"); // "::" = one :0 group
expect(parseIpAddress("::ffff:192.0.2.1")).toBe("::ffff:192.0.2.1");
});
it("rejects malformed IPv6", () => {
expect(parseIpAddress("2001:::1")).toBeNull();
expect(parseIpAddress("2001:db8::g1")).toBeNull();
expect(parseIpAddress("1:2:3:4:5:6:7:8:9")).toBeNull();
expect(parseIpAddress("1:2:3:4:5:6:7:8::")).toBeNull(); // no room for "::"
expect(parseIpAddress("12345::")).toBeNull();
});
});
describe("parsePrefix", () => {
it("accepts IPv4 and IPv6 CIDR", () => {
expect(parsePrefix("192.0.2.0/24")).toBe("192.0.2.0/24");
expect(parsePrefix("192.0.2.1/24")).toBe("192.0.2.1/24"); // host bits: backend normalizes
expect(parsePrefix("0.0.0.0/0")).toBe("0.0.0.0/0");
expect(parsePrefix("2001:db8::/32")).toBe("2001:db8::/32");
expect(parsePrefix("2001:db8::1/128")).toBe("2001:db8::1/128");
});
it("rejects bad lengths and addresses", () => {
expect(parsePrefix("192.0.2.0/33")).toBeNull();
expect(parsePrefix("2001:db8::/129")).toBeNull();
expect(parsePrefix("192.0.2.0/")).toBeNull();
expect(parsePrefix("192.0.2.0/24/1")).toBeNull();
expect(parsePrefix("999.0.0.0/8")).toBeNull();
expect(parsePrefix("/24")).toBeNull();
});
});
describe("classifyNetworkQuery", () => {
it("prefix wins over ip, ip over asn", () => {
expect(classifyNetworkQuery("81.68.0.0/14")).toEqual({
kind: "prefix",
prefix: "81.68.0.0/14",
});
expect(classifyNetworkQuery("81.68.1.1")).toEqual({ kind: "ip", ip: "81.68.1.1" });
expect(classifyNetworkQuery("2001:7fa:0:1::1")).toEqual({
kind: "ip",
ip: "2001:7fa:0:1::1",
});
expect(classifyNetworkQuery("AS45090")).toEqual({ kind: "asn", asn: 45090 });
expect(classifyNetworkQuery("45090")).toEqual({ kind: "asn", asn: 45090 });
});
it("falls back to none for text queries", () => {
expect(classifyNetworkQuery("repo-a.example")).toEqual({ kind: "none" });
expect(classifyNetworkQuery("rsync://repo.example/ca/roa.roa")).toEqual({ kind: "none" });
expect(classifyNetworkQuery("d1488eb0e0faabba1fbe8236")).toEqual({ kind: "none" });
expect(classifyNetworkQuery("")).toEqual({ kind: "none" });
});
});

View File

@@ -0,0 +1,104 @@
/**
* Classify a free-text search query into a VRP lookup target.
*
* The backend VRP lookup (`GET /runs/{id}/vrps/lookup`) accepts exactly one of
* `ip` / `prefix`, plus an optional `asn` filter. An ASN-only query cannot be
* served by the API — the UI surfaces that explicitly instead of guessing.
*/
export type NetworkQuery =
| { kind: "ip"; ip: string }
| { kind: "prefix"; prefix: string }
| { kind: "asn"; asn: number }
| { kind: "none" };
const ASN_MAX = 4_294_967_295;
/** "AS45090" / "as45090" / "45090" → ASN, else null. */
export function parseAsn(input: string): number | null {
const match = /^(?:as)?(\d{1,10})$/i.exec(input.trim());
if (!match) return null;
const asn = Number(match[1]);
return Number.isSafeInteger(asn) && asn <= ASN_MAX ? asn : null;
}
function isIpv4Octets(addr: string): boolean {
const parts = addr.split(".");
return (
parts.length === 4 &&
parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
);
}
function isIpv6(addr: string): boolean {
if (!/^[0-9a-fA-F:.]+$/.test(addr)) return false;
if ((addr.match(/::/g) ?? []).length > 1) return false;
const isGroup = (g: string, isLast: boolean) =>
isLast && g.includes(".")
? isIpv4Octets(g) // embedded IPv4 tail, e.g. "::ffff:192.0.2.1"
: /^[0-9a-fA-F]{1,4}$/.test(g);
const ipv4TailWeight = (groups: string[]) =>
groups.length > 0 && groups[groups.length - 1].includes(".") ? 1 : 0;
if (addr.includes("::")) {
const [head, tail] = addr.split("::");
const headGroups = head ? head.split(":") : [];
const tailGroups = tail ? tail.split(":") : [];
// "::" compresses at least one group; an IPv4 tail counts as two groups.
const effective =
headGroups.length + tailGroups.length + ipv4TailWeight(tailGroups);
if (effective > 7) return false;
return [...headGroups, ...tailGroups].every((g, i, all) =>
isGroup(g, i === all.length - 1),
);
}
const groups = addr.split(":");
const expected = groups[groups.length - 1]?.includes(".") ? 7 : 8;
if (groups.length !== expected) return false;
return groups.every((g, i) => isGroup(g, i === groups.length - 1));
}
/** Bare IPv4/IPv6 address (no "/") → normalized string, else null. */
export function parseIpAddress(input: string): string | null {
const s = input.trim();
if (s.includes("/")) return null;
if (isIpv4Octets(s)) return s;
if (s.includes(":") && isIpv6(s)) return s;
return null;
}
function parsePrefixParts(s: string): [string, number] | null {
const slash = s.indexOf("/");
if (slash < 0 || slash !== s.lastIndexOf("/")) return null;
const addr = s.slice(0, slash);
const lenRaw = s.slice(slash + 1);
if (!/^\d{1,3}$/.test(lenRaw)) return null;
return [addr, Number(lenRaw)];
}
/** "addr/len" CIDR with family-correct length → normalized string, else null. */
export function parsePrefix(input: string): string | null {
const s = input.trim();
const parts = parsePrefixParts(s);
if (!parts) return null;
const [addr, len] = parts;
if (isIpv4Octets(addr)) return len <= 32 ? `${addr}/${len}` : null;
if (addr.includes(":") && isIpv6(addr)) return len <= 128 ? `${addr}/${len}` : null;
return 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.)
*/
export function classifyNetworkQuery(input: string): NetworkQuery {
const s = input.trim();
if (!s) return { kind: "none" };
const prefix = parsePrefix(s);
if (prefix) return { kind: "prefix", prefix };
const ip = parseIpAddress(s);
if (ip) return { kind: "ip", ip };
const asn = parseAsn(s);
if (asn !== null) return { kind: "asn", asn };
return { kind: "none" };
}

View File

@@ -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)" },

View File

@@ -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>

View File

@@ -191,6 +191,23 @@ a.reason-row:hover {
font-size: 13px;
}
.vrp-asn-filter {
display: flex;
gap: var(--space-2);
align-items: center;
font-size: 12px;
color: var(--text-2);
}
.vrp-asn-filter input {
width: 180px;
padding: 5px 9px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-m);
font-family: var(--font-mono);
font-size: 12px;
}
/* Validation page */
.validation-summary {
display: grid;