rpki/ui/rpki-explorer/src/components/ObjectsTable.tsx

219 lines
6.8 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import type { ListResult, ObjectInstanceRecord } from "../api/schemas";
import type { ObjectFilters } from "../api/service";
import { useCursorPager } from "../lib/cursor";
import { objectTypeLabel, truncateMiddle } from "../lib/format";
import { withRunParam } from "../lib/run";
import { CopyableValue } from "./CopyableValue";
import { CursorPagerControls } from "./CursorPagerControls";
import { DataTable, type Column } from "./DataTable";
import { StatusPill } from "./StatusPill";
export interface ObjectFilterValues {
type: string;
result: string;
rejectedOnly: boolean;
q: string;
}
export const EMPTY_OBJECT_FILTERS: ObjectFilterValues = {
type: "",
result: "",
rejectedOnly: false,
q: "",
};
/** 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"];
/**
* Filter bar + paginated object table. The `fetcher` determines the scope
* (global / repo / publication point / issues). Filter state is controlled
* by the caller so standalone pages can sync it to the URL.
*/
export function ObjectsTable({
runId,
queryKeyScope,
fetcher,
filters,
onFiltersChange,
showReason = true,
}: {
runId: string;
queryKeyScope: string;
fetcher: (filters: ObjectFilters) => Promise<ListResult<ObjectInstanceRecord>>;
filters: ObjectFilterValues;
onFiltersChange: (next: ObjectFilterValues) => void;
showReason?: boolean;
}) {
const navigate = useNavigate();
const [qDraft, setQDraft] = useState(filters.q);
// Keep the draft input in sync when filters change externally (URL nav).
useEffect(() => {
setQDraft(filters.q);
}, [filters.q]);
const filterKey = JSON.stringify([runId, filters.type, filters.result, filters.rejectedOnly, filters.q]);
const pager = useCursorPager(filterKey);
const query = useQuery({
queryKey: ["objects", queryKeyScope, runId, filters, pager.cursor],
queryFn: () =>
fetcher({
limit: 50,
cursor: pager.cursor,
type: filters.type || undefined,
result: filters.result || undefined,
rejected: filters.rejectedOnly || undefined,
q: filters.q || undefined,
}),
placeholderData: (prev) => prev,
});
const columns: Column<ObjectInstanceRecord>[] = useMemo(
() => [
{
key: "type",
header: "Type",
render: (obj) => <span className="chip">{objectTypeLabel(obj.objectType)}</span>,
},
{
key: "uri",
header: "URI",
render: (obj) => <CopyableValue value={obj.uri} max={64} label="object URI" />,
},
{
key: "sha256",
header: "SHA-256",
render: (obj) => (
<span className="mono text-muted" title={obj.sha256 ?? undefined}>
{obj.sha256 ? truncateMiddle(obj.sha256, 18) : "—"}
</span>
),
},
{
key: "source",
header: "Source",
render: (obj) => <StatusPill status={obj.sourceSection} />,
},
{
key: "result",
header: "Result",
render: (obj) => <StatusPill status={obj.rejected ? "rejected" : obj.result} />,
},
...(showReason
? [
{
key: "reason",
header: "Reject reason",
render: (obj: ObjectInstanceRecord) =>
obj.rejectReason ? (
<span className="mono text-small" title={obj.rejectReason}>
{truncateMiddle(obj.rejectReason, 60)}
</span>
) : (
<span className="text-faint"></span>
),
} satisfies Column<ObjectInstanceRecord>,
]
: []),
],
[showReason],
);
const submitQ = () => {
if (qDraft.trim() !== filters.q) {
onFiltersChange({ ...filters, q: qDraft.trim() });
}
};
return (
<div>
<div className="panel-header filter-bar" role="search">
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-type`}>Type</label>
<select
id={`${queryKeyScope}-type`}
value={filters.type}
onChange={(e) => onFiltersChange({ ...filters, type: e.target.value })}
>
<option value="">all types</option>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{objectTypeLabel(t)}
</option>
))}
</select>
</div>
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-result`}>Result</label>
<select
id={`${queryKeyScope}-result`}
value={filters.result}
onChange={(e) => onFiltersChange({ ...filters, result: e.target.value })}
>
<option value="">all results</option>
{RESULT_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<div className="filter-field">
<label htmlFor={`${queryKeyScope}-q`}>URI contains</label>
<input
id={`${queryKeyScope}-q`}
type="search"
className="mono"
value={qDraft}
onChange={(e) => setQDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitQ();
}}
onBlur={submitQ}
placeholder="substring…"
/>
</div>
<label className="filter-check">
<input
type="checkbox"
checked={filters.rejectedOnly}
onChange={(e) => onFiltersChange({ ...filters, rejectedOnly: e.target.checked })}
/>
Rejected only
</label>
<span className="text-faint text-small" style={{ marginLeft: "auto" }}>
server-side filters
</span>
</div>
<DataTable
columns={columns}
rows={query.data?.items ?? []}
rowKey={(obj) => obj.objectInstanceId}
loading={query.isPending}
error={query.isError ? query.error : undefined}
onRetry={() => query.refetch()}
emptyTitle="No objects match"
emptyHint="Try relaxing the filters."
caption="RPKI objects"
onRowClick={(obj) =>
navigate(withRunParam(`/objects/${encodeURIComponent(obj.objectInstanceId)}`, runId))
}
/>
<CursorPagerControls
page={pager.page}
canPrev={pager.canPrev}
hasNext={query.data?.nextCursor != null}
loading={query.isFetching}
onPrev={pager.goPrev}
onNext={() => pager.goNext(query.data?.nextCursor ?? null)}
itemCount={query.data?.items.length}
/>
</div>
);
}