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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user