219 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|