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:
2026-07-27 13:05:56 +08:00
parent 001f13209f
commit cf68abf642
36 changed files with 1082 additions and 125 deletions

View File

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

View File

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

View File

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

View File

@@ -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 !== "";

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)} />

View File

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

View File

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

View File

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