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

@@ -86,6 +86,19 @@ export function DataTable<T>({
key={rowKey(row)}
className={onRowClick ? "clickable" : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
// Clickable rows behave like links: focusable and Enter/Space-activated.
tabIndex={onRowClick ? 0 : undefined}
role={onRowClick ? "link" : undefined}
onKeyDown={
onRowClick
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onRowClick(row);
}
}
: undefined
}
>
{columns.map((col) => (
<td

View File

@@ -14,16 +14,48 @@ import { StatusPill } from "./StatusPill";
export interface ObjectFilterValues {
type: string;
result: string;
reason: string;
rejectedOnly: boolean;
q: string;
}
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
type: "",
result: "",
reason: "",
rejectedOnly: false,
q: "",
};
/** Read object filters from the page URL (standalone pages sync them there). */
export function objectFiltersFromParams(params: URLSearchParams): ObjectFilterValues {
return {
type: params.get("type") ?? "",
result: params.get("result") ?? "",
reason: params.get("reason") ?? "",
rejectedOnly: params.get("rejected") === "true",
q: params.get("q") ?? "",
};
}
/** Write object filters into a URLSearchParams copy, preserving other params. */
export function objectFiltersToParams(
next: ObjectFilterValues,
base: URLSearchParams,
): URLSearchParams {
const params = new URLSearchParams(base);
const write = (key: string, value: string) => {
if (value) params.set(key, value);
else params.delete(key);
};
write("type", next.type);
write("result", next.result);
write("reason", next.reason);
write("q", next.q);
if (next.rejectedOnly) params.set("rejected", "true");
else params.delete("rejected");
return params;
}
/** Report-stream object type vocabulary (backend also accepts mft/cer/asa aliases). */
const TYPE_OPTIONS = ["roa", "manifest", "crl", "certificate", "aspa", "gbr"];
const RESULT_OPTIONS = ["ok", "error", "skipped"];
@@ -55,7 +87,16 @@ export function ObjectsTable({
useEffect(() => {
setQDraft(filters.q);
}, [filters.q]);
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]);
// Auto-apply the URI draft after a short typing pause (Enter/blur still work).
useEffect(() => {
const trimmed = qDraft.trim();
if (trimmed === filters.q) return;
const timer = setTimeout(() => onFiltersChange({ ...filters, q: trimmed }), 400);
return () => clearTimeout(timer);
}, [qDraft, filters, onFiltersChange]);
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.reason, filters.rejectedOnly, filters.q]);
const pager = useCursorPager(filterKey);
const query = useQuery({
@@ -67,6 +108,7 @@ export function ObjectsTable({
type: filters.type || undefined,
result: filters.result || undefined,
rejected: filters.rejectedOnly || undefined,
reason: filters.reason || undefined,
q: filters.q || undefined,
}),
placeholderData: (prev) => prev,
@@ -101,7 +143,9 @@ export function ObjectsTable({
{
key: "result",
header: "Result",
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
// Raw `result` value — the Result filter options must match what the
// column displays (rejection is visible via the reason column/filter).
render: (obj) => <StatusPill status={obj.result} />,
},
...(showReason
? [
@@ -162,6 +206,17 @@ export function ObjectsTable({
))}
</select>
</div>
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-reason`}>Reason contains</label>
<input
id={`${queryKeyScope}-reason`}
type="search"
className="mono"
value={filters.reason}
onChange={(e) => onFiltersChange({ ...filters, reason: e.target.value })}
placeholder="substring…"
/>
</div>
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
<input
@@ -174,7 +229,7 @@ export function ObjectsTable({
if (e.key === "Enter") submitQ();
}}
onBlur={submitQ}
placeholder="substring…"
placeholder="substring… (applies as you type)"
/>
</div>
<label className="filter-check">
@@ -186,7 +241,7 @@ export function ObjectsTable({
Rejected only
</label>
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
server-side filters
server-side filters · URI search applies as you type
</span>
</div>

View File

@@ -182,12 +182,16 @@ function RevokedCerts({ runId, objectInstanceId }: { runId: string; objectInstan
placeholderData: (prev) => prev,
});
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | null; revocationDate?: string | null }>[] = [
const columns: Column<{ serialNumberHex?: string | null; serialNumber?: string | number | null; revocationDate?: string | null }>[] = [
{
key: "serial",
header: "Serial number",
render: (row) => (
<CopyableValue value={row.serialNumberHex ?? row.serialNumber} max={44} label="serial number" />
<CopyableValue
value={row.serialNumberHex ?? (row.serialNumber != null ? String(row.serialNumber) : null)}
max={44}
label="serial number"
/>
),
},
{ key: "date", header: "Revocation date", render: (row) => formatUtc(row.revocationDate) },

View File

@@ -129,6 +129,9 @@ export function Shell({ children }: { children: ReactNode }) {
return (
<div className={`shell${collapsed ? " collapsed" : ""}`}>
<a href="#main-content" className="skip-link">
Skip to content
</a>
<div className="shell-brand">
<span className="brand-badge" aria-hidden="true">
<ShieldCheck size={15} />
@@ -170,7 +173,9 @@ export function Shell({ children }: { children: ReactNode }) {
<div className="nav-footer">ours RP query layer</div>
</nav>
<main className="shell-main">{children}</main>
<main className="shell-main" id="main-content" tabIndex={-1}>
{children}
</main>
</div>
);
}

View File

@@ -49,7 +49,9 @@ export function Tabs({
role="tab"
id={`tab-${tab.id}`}
aria-selected={selected}
aria-controls={`tabpanel-${tab.id}`}
// Only the active tab's panel is mounted — pointing aria-controls
// at an unmounted panel would leave a dangling reference.
aria-controls={selected ? `tabpanel-${tab.id}` : undefined}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(tab.id)}
>