20260718 Explorer 接入 VRP 检索查询
This commit is contained in:
parent
6d9eedd680
commit
06f4b086b4
@ -23,7 +23,12 @@ not change validation logic or RP state.
|
|||||||
- **Validation** — result distribution, reject-reason distribution with
|
- **Validation** — result distribution, reject-reason distribution with
|
||||||
click-to-filter, paginated issue list (`/issues`).
|
click-to-filter, paginated issue list (`/issues`).
|
||||||
- **Search** — unified lookup: exact object URI, SHA-256 (full or ≥8 hex
|
- **Search** — unified lookup: exact object URI, SHA-256 (full or ≥8 hex
|
||||||
prefix), repository host/URI substring, PP URI substring.
|
prefix), repository host/URI substring, PP URI substring — plus **VRP
|
||||||
|
lookup**: enter an IPv4/IPv6 address or CIDR prefix to list the covering
|
||||||
|
VRPs (prefix queries use covering semantics: VRP prefix must cover the whole
|
||||||
|
queried prefix with `maxLength` ≥ queried length), with an optional ASN
|
||||||
|
filter and cursor paging. ASN-only queries are not supported by the API and
|
||||||
|
the page says so.
|
||||||
- **Exports** — export job list with live polling and tarball download.
|
- **Exports** — export job list with live polling and tarball download.
|
||||||
- **Runs** — indexed run history; pin any run as the browsing context via the
|
- **Runs** — indexed run history; pin any run as the browsing context via the
|
||||||
run selector in the top bar (`?run=` URL param, deep-linkable everywhere).
|
run selector in the top bar (`?run=` URL param, deep-linkable everywhere).
|
||||||
@ -41,8 +46,9 @@ query-db built by `rpki_query_indexer`. Some features need extra service flags:
|
|||||||
|
|
||||||
The service has **no CORS headers and no authentication** — the UI must be
|
The service has **no CORS headers and no authentication** — the UI must be
|
||||||
served same-origin with the API behind a proxy (dev: Vite proxy; prod: reverse
|
served same-origin with the API behind a proxy (dev: Vite proxy; prod: reverse
|
||||||
proxy). VRP IP/prefix/ASN lookup is not available yet (backend feature #070);
|
proxy). VRP lookup needs runs indexed at query-db schema version 2 (`#122`);
|
||||||
the search page says so explicitly when such a query is detected.
|
re-index older runs with `rpki_query_indexer` (or the service
|
||||||
|
`--watch-run-root` backfill), otherwise lookup results are empty.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@ -209,6 +209,15 @@ export const searchResultSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
|
export const vrpRecordSchema = z
|
||||||
|
.object({
|
||||||
|
runId: z.string(),
|
||||||
|
asn: z.number(),
|
||||||
|
prefix: z.string(),
|
||||||
|
maxLength: z.number(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export const healthSchema = z
|
export const healthSchema = z
|
||||||
.object({
|
.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
@ -295,6 +304,7 @@ export type ValidationStage = z.infer<typeof validationStageSchema>;
|
|||||||
export type ValidationSummary = z.infer<typeof validationSummarySchema>;
|
export type ValidationSummary = z.infer<typeof validationSummarySchema>;
|
||||||
export type ExplainRecord = z.infer<typeof explainRecordSchema>;
|
export type ExplainRecord = z.infer<typeof explainRecordSchema>;
|
||||||
export type SearchResult = z.infer<typeof searchResultSchema>;
|
export type SearchResult = z.infer<typeof searchResultSchema>;
|
||||||
|
export type VrpRecord = z.infer<typeof vrpRecordSchema>;
|
||||||
export type HealthStatus = z.infer<typeof healthSchema>;
|
export type HealthStatus = z.infer<typeof healthSchema>;
|
||||||
export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
|
export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
|
||||||
export type ManifestFileEntry = z.infer<typeof manifestFileEntrySchema>;
|
export type ManifestFileEntry = z.infer<typeof manifestFileEntrySchema>;
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
serviceInfoSchema,
|
serviceInfoSchema,
|
||||||
statsMapSchema,
|
statsMapSchema,
|
||||||
validationSummarySchema,
|
validationSummarySchema,
|
||||||
|
vrpRecordSchema,
|
||||||
type ChainEdgeRecord,
|
type ChainEdgeRecord,
|
||||||
type ExportJobRecord,
|
type ExportJobRecord,
|
||||||
type ExplainRecord,
|
type ExplainRecord,
|
||||||
@ -38,6 +39,7 @@ import {
|
|||||||
type SearchResult,
|
type SearchResult,
|
||||||
type ServiceInfo,
|
type ServiceInfo,
|
||||||
type ValidationSummary,
|
type ValidationSummary,
|
||||||
|
type VrpRecord,
|
||||||
} from "./schemas";
|
} from "./schemas";
|
||||||
|
|
||||||
const API = "/api/v1";
|
const API = "/api/v1";
|
||||||
@ -387,6 +389,26 @@ export function searchRun(
|
|||||||
return getData(`${runBase(runId)}/search`, searchResultSchema, { q, limit });
|
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 */
|
/* Stats */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
97
ui/rpki-explorer/src/lib/vrpQuery.test.ts
Normal file
97
ui/rpki-explorer/src/lib/vrpQuery.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
104
ui/rpki-explorer/src/lib/vrpQuery.ts
Normal file
104
ui/rpki-explorer/src/lib/vrpQuery.ts
Normal 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" };
|
||||||
|
}
|
||||||
@ -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}/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}/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}/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: "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: "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)" },
|
{ 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 { Link, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FileBox, FolderTree, Server } from "lucide-react";
|
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 { PageHeader } from "../components/PageHeader";
|
||||||
import { Panel } from "../components/Panel";
|
import { Panel } from "../components/Panel";
|
||||||
import { StatusPill } from "../components/StatusPill";
|
import { StatusPill } from "../components/StatusPill";
|
||||||
import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock";
|
import { EmptyBlock, ErrorBlock, Notice } from "../components/StateBlock";
|
||||||
|
import { useCursorPager } from "../lib/cursor";
|
||||||
import { objectTypeLabel } from "../lib/format";
|
import { objectTypeLabel } from "../lib/format";
|
||||||
import { withRunParam } from "../lib/run";
|
import { withRunParam } from "../lib/run";
|
||||||
import { useRun } from "../lib/useRun";
|
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 (
|
return (
|
||||||
/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(q) || // IPv4 / prefix
|
<Panel
|
||||||
/^[0-9a-fA-F:]*:[0-9a-fA-F:.]*(\/\d{1,3})?$/.test(q) || // IPv6-ish
|
title="VRP lookup"
|
||||||
/^(AS)?\d{1,10}$/i.test(q) // ASN
|
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]);
|
useEffect(() => setDraft(q), [q]);
|
||||||
|
|
||||||
|
const network: NetworkQuery = useMemo(() => classifyNetworkQuery(q), [q]);
|
||||||
|
const isVrpLookup = network.kind === "ip" || network.kind === "prefix";
|
||||||
|
|
||||||
const searchQuery = useQuery({
|
const searchQuery = useQuery({
|
||||||
queryKey: ["search", runId, q],
|
queryKey: ["search", runId, q],
|
||||||
queryFn: () => searchRun(runId, q, 10),
|
queryFn: () => searchRun(runId, q, 10),
|
||||||
enabled: q.trim().length > 0,
|
enabled: !isVrpLookup && q.trim().length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (event: FormEvent) => {
|
const onSubmit = (event: FormEvent) => {
|
||||||
@ -39,6 +160,7 @@ export default function SearchPage() {
|
|||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
if (draft.trim()) params.set("q", draft.trim());
|
if (draft.trim()) params.set("q", draft.trim());
|
||||||
else params.delete("q");
|
else params.delete("q");
|
||||||
|
params.delete("asn");
|
||||||
setSearchParams(params, { preventScrollReset: true });
|
setSearchParams(params, { preventScrollReset: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,7 +174,7 @@ export default function SearchPage() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Search"
|
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}>
|
<form className="search-hero" role="search" onSubmit={onSubmit}>
|
||||||
@ -60,7 +182,7 @@ export default function SearchPage() {
|
|||||||
type="search"
|
type="search"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
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"
|
aria-label="Search query"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@ -69,19 +191,21 @@ export default function SearchPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{q && looksLikeNetworkQuery(q) ? (
|
{network.kind === "asn" ? (
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
IP prefix and ASN lookup of VRPs requires backend feature #070 (VRP lookup),
|
The VRP API cannot list VRPs by ASN alone — query an IP address or a
|
||||||
which is not implemented yet. The results below only cover objects,
|
CIDR prefix first, then narrow the hits with the ASN filter. The
|
||||||
repositories and publication points.
|
results below only cover objects, repositories and publication points.
|
||||||
</Notice>
|
</Notice>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!q ? (
|
{!q ? (
|
||||||
<EmptyBlock
|
<EmptyBlock
|
||||||
title="Enter a query"
|
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 ? (
|
) : searchQuery.isError ? (
|
||||||
<ErrorBlock error={searchQuery.error} onRetry={() => searchQuery.refetch()} title="Search failed" />
|
<ErrorBlock error={searchQuery.error} onRetry={() => searchQuery.refetch()} title="Search failed" />
|
||||||
) : searchQuery.isPending ? (
|
) : searchQuery.isPending ? (
|
||||||
@ -162,8 +286,9 @@ export default function SearchPage() {
|
|||||||
<ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
|
<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><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>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>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>
|
</ul>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -191,6 +191,23 @@ a.reason-row:hover {
|
|||||||
font-size: 13px;
|
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 page */
|
||||||
.validation-summary {
|
.validation-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -319,3 +319,20 @@ export const HEALTH = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICE_INFO = { service: "rpki_query_service", version: 1 };
|
export const SERVICE_INFO = { service: "rpki_query_service", version: 1 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VRP lookup fixtures (backend feature #122). 61 rows match `ip=81.68.1.1`
|
||||||
|
* so the UI's 50-row page size paginates; the /16 row exercises covering
|
||||||
|
* semantics for prefix queries, the IPv6 row exercises the second family.
|
||||||
|
*/
|
||||||
|
export const VRPS = [
|
||||||
|
{ runId: "run_0002", asn: 45090, prefix: "81.68.0.0/14", maxLength: 14 },
|
||||||
|
{ runId: "run_0002", asn: 64496, prefix: "81.68.0.0/16", maxLength: 24 },
|
||||||
|
...Array.from({ length: 59 }, (_, i) => ({
|
||||||
|
runId: "run_0002",
|
||||||
|
asn: 64512 + i,
|
||||||
|
prefix: "81.68.0.0/14",
|
||||||
|
maxLength: 14,
|
||||||
|
})),
|
||||||
|
{ runId: "run_0002", asn: 0, prefix: "2001:7fa:0:1::/64", maxLength: 64 },
|
||||||
|
];
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
STATS_REASONS,
|
STATS_REASONS,
|
||||||
STATS_VALIDATION,
|
STATS_VALIDATION,
|
||||||
VALIDATION_SUMMARY,
|
VALIDATION_SUMMARY,
|
||||||
|
VRPS,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
|
|
||||||
export interface MockJob {
|
export interface MockJob {
|
||||||
@ -74,6 +75,63 @@ function paginate<T>(items: T[], url: URL, defaultLimit = 50): { page: T[]; next
|
|||||||
return { page, next: nextOffset < items.length ? String(nextOffset) : null };
|
return { page, next: nextOffset < items.length ? String(nextOffset) : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** VRP lookup pagination with the backend's `vrp:<offset>` cursor format. */
|
||||||
|
function paginateVrp<T>(items: T[], url: URL): { page: T[]; next: string | null } {
|
||||||
|
const limit = Math.max(1, Number(url.searchParams.get("limit") ?? 50));
|
||||||
|
const raw = url.searchParams.get("cursor") ?? "";
|
||||||
|
const offset = raw.startsWith("vrp:") ? Number(raw.slice(4)) || 0 : 0;
|
||||||
|
const page = items.slice(offset, offset + limit);
|
||||||
|
const nextOffset = offset + limit;
|
||||||
|
return { page, next: nextOffset < items.length ? `vrp:${nextOffset}` : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipv4ToInt(addr: string): number | null {
|
||||||
|
const parts = addr.split(".").map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
||||||
|
return ((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock coverage semantics: VRP covers an IP when its prefix contains it;
|
||||||
|
* covers a queried prefix when the VRP prefix contains it (shorter or equal
|
||||||
|
* length) AND vrp.maxLength >= queried length. IPv6 uses a leading-hextet
|
||||||
|
* heuristic — good enough for the fixture dataset.
|
||||||
|
*/
|
||||||
|
function vrpCovers(
|
||||||
|
vrp: { prefix: string; maxLength: number },
|
||||||
|
query: { ip?: string; prefix?: string },
|
||||||
|
): boolean {
|
||||||
|
const [net, lenRaw] = vrp.prefix.split("/");
|
||||||
|
const vlen = Number(lenRaw);
|
||||||
|
if (vrp.prefix.includes(":")) {
|
||||||
|
const head = net.split("::")[0].replace(/:$/, "");
|
||||||
|
const target = query.ip ?? query.prefix?.split("/")[0] ?? "";
|
||||||
|
if (!target.includes(":")) return false;
|
||||||
|
if (query.prefix) {
|
||||||
|
const qlen = Number(query.prefix.split("/")[1]);
|
||||||
|
if (!(vlen <= qlen && vrp.maxLength >= qlen)) return false;
|
||||||
|
}
|
||||||
|
return target === net || target.startsWith(`${head}:`);
|
||||||
|
}
|
||||||
|
if (query.ip) {
|
||||||
|
if (query.ip.includes(":")) return false;
|
||||||
|
const netInt = ipv4ToInt(net);
|
||||||
|
const ipInt = ipv4ToInt(query.ip);
|
||||||
|
if (netInt === null || ipInt === null) return false;
|
||||||
|
return vlen === 0 || netInt >>> (32 - vlen) === ipInt >>> (32 - vlen);
|
||||||
|
}
|
||||||
|
if (query.prefix) {
|
||||||
|
const [qnet, qlenRaw] = query.prefix.split("/");
|
||||||
|
const qlen = Number(qlenRaw);
|
||||||
|
const netInt = ipv4ToInt(net);
|
||||||
|
const qnetInt = ipv4ToInt(qnet);
|
||||||
|
if (netInt === null || qnetInt === null) return false;
|
||||||
|
if (!(vlen <= qlen && vrp.maxLength >= qlen)) return false;
|
||||||
|
return vlen === 0 || netInt >>> (32 - vlen) === qnetInt >>> (32 - vlen);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function applyObjectFilters(items: typeof OBJECTS, url: URL): typeof OBJECTS {
|
function applyObjectFilters(items: typeof OBJECTS, url: URL): typeof OBJECTS {
|
||||||
let out = items;
|
let out = items;
|
||||||
const type = url.searchParams.get("type")?.toLowerCase();
|
const type = url.searchParams.get("type")?.toLowerCase();
|
||||||
@ -280,6 +338,24 @@ export async function installMockApi(
|
|||||||
return fulfillJson(route, pageItems, next);
|
return fulfillJson(route, pageItems, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section === "vrps" && a === "lookup") {
|
||||||
|
const ip = url.searchParams.get("ip") ?? undefined;
|
||||||
|
const prefix = url.searchParams.get("prefix") ?? undefined;
|
||||||
|
if (ip && prefix) return fulfillError(route, 400, "ip and prefix are mutually exclusive");
|
||||||
|
if (!ip && !prefix) return fulfillError(route, 400, "ip or prefix query parameter is required");
|
||||||
|
const asnRaw = url.searchParams.get("asn");
|
||||||
|
let items = VRPS.filter((v) => vrpCovers(v, { ip, prefix }));
|
||||||
|
if (asnRaw !== null) {
|
||||||
|
const asn = Number(asnRaw);
|
||||||
|
if (!Number.isInteger(asn) || asn < 0) {
|
||||||
|
return fulfillError(route, 400, `invalid ASN query parameter: ${asnRaw}`);
|
||||||
|
}
|
||||||
|
items = items.filter((v) => v.asn === asn);
|
||||||
|
}
|
||||||
|
const { page: vrpPage, next } = paginateVrp(items, url);
|
||||||
|
return fulfillJson(route, vrpPage, next);
|
||||||
|
}
|
||||||
|
|
||||||
if (section === "search") {
|
if (section === "search") {
|
||||||
const q = (url.searchParams.get("q") ?? "").trim();
|
const q = (url.searchParams.get("q") ?? "").trim();
|
||||||
if (!q) return fulfillError(route, 400, "q query parameter is required");
|
if (!q) return fulfillError(route, 400, "q query parameter is required");
|
||||||
|
|||||||
@ -48,9 +48,64 @@ test("search page: exact URI finds the object", async ({ page }) => {
|
|||||||
await expect(page).toHaveURL(/\/objects\/obj-roa-0001/);
|
await expect(page).toHaveURL(/\/objects\/obj-roa-0001/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("search page: ASN/prefix queries show the #070 capability notice", async ({ page }) => {
|
test("search page: IP query runs a VRP lookup with pagination", async ({ page }) => {
|
||||||
await page.goto("/search?q=AS65001");
|
await page.goto("/search?q=81.68.1.1");
|
||||||
await expect(page.getByRole("status").filter({ hasText: "feature #070" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "VRP lookup" })).toBeVisible();
|
||||||
|
|
||||||
|
// 61 matching fixture rows, page size 50 → first page shows row 1..50
|
||||||
|
await expect(page.getByRole("cell", { name: "AS45090" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "81.68.0.0/14" }).first()).toBeVisible();
|
||||||
|
await expect(page.getByText("Page 1 · 50 rows")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
await expect(page.getByText("Page 2 · 11 rows")).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "AS64570" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Previous" }).click();
|
||||||
|
await expect(page.getByText("Page 1 · 50 rows")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: prefix query applies covering semantics", async ({ page }) => {
|
||||||
|
// /14 VRPs have maxLength 14 < 16 and must NOT match a /16 query;
|
||||||
|
// only the 81.68.0.0/16 maxLength 24 VRP covers it.
|
||||||
|
await page.goto("/search?q=81.68.0.0/16");
|
||||||
|
await expect(page.getByRole("heading", { name: "VRP lookup" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "AS64496" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "81.68.0.0/16" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "/24" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "AS45090" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: IPv6 query returns the v6 VRP", async ({ page }) => {
|
||||||
|
await page.goto("/search?q=2001:7fa:0:1::1");
|
||||||
|
await expect(page.getByRole("cell", { name: "AS0", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "2001:7fa:0:1::/64" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: ASN filter narrows VRP results", async ({ page }) => {
|
||||||
|
await page.goto("/search?q=81.68.1.1");
|
||||||
|
await expect(page.getByRole("cell", { name: "AS45090" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("ASN filter").fill("AS64496");
|
||||||
|
await page.getByRole("button", { name: "Apply" }).click();
|
||||||
|
await expect(page).toHaveURL(/asn=64496/);
|
||||||
|
await expect(page.getByRole("cell", { name: "AS64496" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("cell", { name: "AS45090" })).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Page 1 · 1 rows")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: invalid ASN filter shows an inline warning", async ({ page }) => {
|
||||||
|
await page.goto("/search?q=81.68.1.1");
|
||||||
|
await page.getByLabel("ASN filter").fill("AS123x");
|
||||||
|
await page.getByRole("button", { name: "Apply" }).click();
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "not a valid ASN" })).toBeVisible();
|
||||||
|
await expect(page).not.toHaveURL(/asn=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search page: ASN-only query shows the API limitation notice", async ({ page }) => {
|
||||||
|
await page.goto("/search?q=AS45090");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("status").filter({ hasText: "cannot list VRPs by ASN alone" }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("search page: empty result state", async ({ page }) => {
|
test("search page: empty result state", async ({ page }) => {
|
||||||
|
|||||||
@ -18,6 +18,8 @@ export default defineConfig(({ mode }) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api/v1": {
|
"/api/v1": {
|
||||||
target: queryServiceTarget,
|
target: queryServiceTarget,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user