feat: add sorting and pagination to Docker events table

This commit is contained in:
Mauricio Siu
2026-08-12 04:09:39 -06:00
parent daba2d7fbc
commit fa7a54b4eb
@@ -1,4 +1,14 @@
import { Activity, Loader2, RefreshCw } from "lucide-react"; import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Activity, ArrowUpDown, Loader2, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -32,6 +42,7 @@ interface Props {
} }
type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number]; type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number];
type EventRow = DockerEvent & { key: string };
const RANGE_OPTIONS = [ const RANGE_OPTIONS = [
{ label: "Last 5 minutes", value: 5 }, { label: "Last 5 minutes", value: 5 },
@@ -73,9 +84,46 @@ const getTypeVariant = (type?: string): BadgeVariant =>
const getActionVariant = (action?: string): BadgeVariant => const getActionVariant = (action?: string): BadgeVariant =>
(action && ACTION_VARIANTS[action]) || "blank"; (action && ACTION_VARIANTS[action]) || "blank";
const getResource = (event: DockerEvent) =>
event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-";
const getAttributesText = (event: DockerEvent) => {
const attributes = Object.entries(event.Actor?.Attributes ?? {}).filter(
([key]) => key !== "name",
);
return attributes.map(([key, value]) => `${key}=${value}`).join(" ") || "-";
};
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
export const ShowDockerEvents = ({ serverId }: Props) => { export const ShowDockerEvents = ({ serverId }: Props) => {
const [minutes, setMinutes] = useState(15); const [minutes, setMinutes] = useState(15);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [sorting, setSorting] = useState<SortingState>([
{ id: "time", desc: true },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 15,
});
const { data, isLoading, isRefetching, refetch, error } = const { data, isLoading, isRefetching, refetch, error } =
api.docker.getEvents.useQuery({ api.docker.getEvents.useQuery({
@@ -83,7 +131,14 @@ export const ShowDockerEvents = ({ serverId }: Props) => {
minutes, minutes,
}); });
const events = data?.events ?? []; const events = useMemo<EventRow[]>(
() =>
(data?.events ?? []).map((event, index) => ({
...event,
key: `${event.time}-${event.Action}-${index}`,
})),
[data],
);
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
if (!search.trim()) return events; if (!search.trim()) return events;
@@ -98,6 +153,96 @@ export const ShowDockerEvents = ({ serverId }: Props) => {
}); });
}, [events, search]); }, [events, search]);
const columns = useMemo<ColumnDef<EventRow>[]>(
() => [
{
id: "time",
accessorFn: (event) => event.time ?? 0,
header: ({ column }) => <SortableHeader column={column} title="Time" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{row.original.time
? new Date(row.original.time * 1000).toLocaleTimeString()
: "-"}
</span>
),
},
{
id: "type",
accessorFn: (event) => event.Type ?? "",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => (
<Badge variant={getTypeVariant(row.original.Type)}>
{row.original.Type ?? "unknown"}
</Badge>
),
},
{
id: "action",
accessorFn: (event) => event.Action ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Action" />
),
cell: ({ row }) => (
<Badge variant={getActionVariant(row.original.Action)}>
{row.original.Action ?? "-"}
</Badge>
),
},
{
id: "resource",
accessorFn: (event) => getResource(event),
header: ({ column }) => (
<SortableHeader column={column} title="Resource" />
),
cell: ({ row }) => {
const resource = getResource(row.original);
return (
<div
className="max-w-[220px] truncate font-mono text-xs"
title={resource}
>
{resource}
</div>
);
},
},
{
id: "attributes",
accessorFn: (event) => getAttributesText(event),
header: "Attributes",
enableSorting: false,
cell: ({ row }) => {
const attributesText = getAttributesText(row.original);
return (
<div
className="max-w-[360px] truncate text-xs text-muted-foreground"
title={attributesText}
>
{attributesText}
</div>
);
},
},
],
[],
);
const table = useReactTable({
data: filteredEvents,
columns,
getRowId: (row) => row.key,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return ( return (
<div className="w-full"> <div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl"> <Card className="h-full bg-sidebar p-2.5 rounded-xl">
@@ -135,7 +280,10 @@ export const ShowDockerEvents = ({ serverId }: Props) => {
<Input <Input
placeholder="Filter by type, action or name..." placeholder="Filter by type, action or name..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => {
setSearch(e.target.value);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
className="max-w-xs" className="max-w-xs"
/> />
<Select <Select
@@ -157,19 +305,26 @@ export const ShowDockerEvents = ({ serverId }: Props) => {
<div className="rounded-md border overflow-x-auto"> <div className="rounded-md border overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHead className="w-[110px]">Time</TableHead> <TableRow key={headerGroup.id}>
<TableHead className="w-[100px]">Type</TableHead> {headerGroup.headers.map((header) => (
<TableHead className="w-[110px]">Action</TableHead> <TableHead key={header.id}>
<TableHead>Resource</TableHead> {header.isPlaceholder
<TableHead>Attributes</TableHead> ? null
</TableRow> : flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={5} colSpan={columns.length}
className="h-24 text-center text-muted-foreground" className="h-24 text-center text-muted-foreground"
> >
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
@@ -178,56 +333,23 @@ export const ShowDockerEvents = ({ serverId }: Props) => {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : filteredEvents.length ? ( ) : table.getRowModel().rows?.length ? (
filteredEvents.map((event: DockerEvent, index: number) => { table.getRowModel().rows.map((row) => (
const attributes = Object.entries( <TableRow key={row.id}>
event.Actor?.Attributes ?? {}, {row.getVisibleCells().map((cell) => (
).filter(([key]) => key !== "name"); <TableCell key={cell.id}>
const attributesText = {flexRender(
attributes cell.column.columnDef.cell,
.map(([key, value]) => `${key}=${value}`) cell.getContext(),
.join(" ") || "-"; )}
const resource =
event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-";
return (
<TableRow
key={`${event.time}-${event.Action}-${index}`}
>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{event.time
? new Date(event.time * 1000).toLocaleTimeString()
: "-"}
</TableCell> </TableCell>
<TableCell> ))}
<Badge variant={getTypeVariant(event.Type)}> </TableRow>
{event.Type ?? "unknown"} ))
</Badge>
</TableCell>
<TableCell>
<Badge variant={getActionVariant(event.Action)}>
{event.Action ?? "-"}
</Badge>
</TableCell>
<TableCell
className="max-w-[220px] truncate font-mono text-xs"
title={resource}
>
{resource}
</TableCell>
<TableCell
className="max-w-[360px] truncate text-xs text-muted-foreground"
title={attributesText}
>
{attributesText}
</TableCell>
</TableRow>
);
})
) : ( ) : (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={5} colSpan={columns.length}
className="h-24 text-center text-muted-foreground" className="h-24 text-center text-muted-foreground"
> >
No events found in the selected time range. No events found in the selected time range.
@@ -237,6 +359,32 @@ export const ShowDockerEvents = ({ serverId }: Props) => {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</CardContent> </CardContent>
</div> </div>
</Card> </Card>