253 lines
8.5 KiB
TypeScript
253 lines
8.5 KiB
TypeScript
import {
|
|
buildPinStyle,
|
|
type ColumnConfigEntry,
|
|
DraggableColumnHeader,
|
|
PIN_SHADOW_LEFT,
|
|
PIN_SHADOW_RIGHT,
|
|
useHeaderCellRefs,
|
|
usePinOffsets,
|
|
} from "@/components/table";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
|
import type { MCPToolLogEntry, Pagination } from "@/lib/types/logs";
|
|
import { cn } from "@/lib/utils";
|
|
import type { ColumnOrderState, ColumnPinningState, VisibilityState } from "@tanstack/react-table";
|
|
import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table";
|
|
import { ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
|
|
interface DataTableProps {
|
|
columns: ColumnDef<MCPToolLogEntry>[];
|
|
data: MCPToolLogEntry[];
|
|
totalItems: number;
|
|
loading?: boolean;
|
|
pagination: Pagination;
|
|
onPaginationChange: (pagination: Pagination) => void;
|
|
onRowClick?: (log: MCPToolLogEntry, columnId: string) => void;
|
|
onRefresh?: () => void;
|
|
polling?: boolean;
|
|
/** Column config — computed by the parent via useColumnConfig */
|
|
columnEntries: ColumnConfigEntry[];
|
|
columnOrder: ColumnOrderState;
|
|
columnVisibility: VisibilityState;
|
|
columnPinning: ColumnPinningState;
|
|
onToggleColumnVisibility: (id: string) => void;
|
|
onTogglePin: (id: string, side: "left" | "right") => void;
|
|
onReorderColumns: (entries: ColumnConfigEntry[]) => void;
|
|
}
|
|
|
|
export function MCPLogsDataTable({
|
|
columns,
|
|
data,
|
|
totalItems,
|
|
loading = false,
|
|
pagination,
|
|
onPaginationChange,
|
|
onRowClick,
|
|
onRefresh,
|
|
polling = false,
|
|
columnEntries,
|
|
columnOrder,
|
|
columnVisibility,
|
|
columnPinning,
|
|
onToggleColumnVisibility,
|
|
onTogglePin,
|
|
onReorderColumns,
|
|
}: DataTableProps) {
|
|
const [sorting, setSorting] = useState<SortingState>([{ id: pagination.sort_by, desc: pagination.order === "desc" }]);
|
|
|
|
const fixedColumnIds = useMemo(() => new Set<string>([]), []);
|
|
|
|
// Measure actual header cell widths for pixel-perfect pin offsets
|
|
const { headerCellRefs, setHeaderCellRef } = useHeaderCellRefs();
|
|
const pinOffsets = usePinOffsets(headerCellRefs, columnPinning);
|
|
|
|
// Shadow on the edge of pinned groups
|
|
const lastLeftPinId = columnPinning.left?.at(-1);
|
|
const firstRightPinId = columnPinning.right?.at(0);
|
|
|
|
// Handle native drag-and-drop reorder
|
|
const handleColumnDrop = useCallback(
|
|
(draggedId: string, targetId: string) => {
|
|
const newEntries = [...columnEntries];
|
|
const draggedIdx = newEntries.findIndex((e) => e.id === draggedId);
|
|
const targetIdx = newEntries.findIndex((e) => e.id === targetId);
|
|
if (draggedIdx === -1 || targetIdx === -1) return;
|
|
const [moved] = newEntries.splice(draggedIdx, 1);
|
|
newEntries.splice(targetIdx, 0, moved);
|
|
onReorderColumns(newEntries);
|
|
},
|
|
[columnEntries, onReorderColumns],
|
|
);
|
|
|
|
const handleSortingChange = (updaterOrValue: SortingState | ((old: SortingState) => SortingState)) => {
|
|
const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
|
|
setSorting(newSorting);
|
|
if (newSorting.length > 0) {
|
|
const { id, desc } = newSorting[0];
|
|
onPaginationChange({
|
|
...pagination,
|
|
sort_by: id as "timestamp" | "latency",
|
|
order: desc ? "desc" : "asc",
|
|
});
|
|
}
|
|
};
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
manualPagination: true,
|
|
manualSorting: true,
|
|
manualFiltering: true,
|
|
pageCount: Math.ceil(totalItems / pagination.limit),
|
|
state: {
|
|
sorting,
|
|
columnOrder,
|
|
columnVisibility,
|
|
columnPinning,
|
|
},
|
|
onSortingChange: handleSortingChange,
|
|
});
|
|
|
|
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1;
|
|
const totalPages = Math.ceil(totalItems / pagination.limit);
|
|
const startItem = pagination.offset + 1;
|
|
const endItem = Math.min(pagination.offset + pagination.limit, totalItems);
|
|
|
|
// Display values that handle the case when totalItems is 0
|
|
const startItemDisplay = totalItems === 0 ? 0 : startItem;
|
|
const endItemDisplay = totalItems === 0 ? 0 : endItem;
|
|
|
|
const goToPage = (page: number) => {
|
|
const newOffset = (page - 1) * pagination.limit;
|
|
onPaginationChange({
|
|
...pagination,
|
|
offset: newOffset,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="flex grow flex-col gap-2 overflow-y-auto px-4 pb-2">
|
|
<div className="flex h-full grow flex-col gap-2">
|
|
<div className="grow overflow-y-auto rounded-sm border">
|
|
<Table containerClassName="h-full">
|
|
<thead className={cn("sticky top-0 z-10 bg-[#f9f9f9] dark:bg-[#27272a] px-2 [&_tr]:border-b")}>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr
|
|
key={headerGroup.id}
|
|
className="hover:bg-muted/50 dark:hover:bg-muted/75 data-[state=selected]:bg-muted border-b transition-colors"
|
|
>
|
|
{headerGroup.headers.map((header) => (
|
|
<DraggableColumnHeader
|
|
key={header.id}
|
|
header={header}
|
|
isConfigurable={!fixedColumnIds.has(header.column.id)}
|
|
pinStyle={buildPinStyle(header.column, pinOffsets)}
|
|
pinnedHeaderClassName="bg-[#f9f9f9] dark:bg-[#27272a]"
|
|
className={cn(
|
|
header.column.id === lastLeftPinId && PIN_SHADOW_LEFT,
|
|
header.column.id === firstRightPinId && PIN_SHADOW_RIGHT,
|
|
)}
|
|
onHide={onToggleColumnVisibility}
|
|
onPin={onTogglePin}
|
|
onDrop={handleColumnDrop}
|
|
cellRef={setHeaderCellRef(header.column.id)}
|
|
/>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<TableBody>
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableCell colSpan={columns.length} className="h-12 text-center">
|
|
<div className="text-muted-foreground flex items-center justify-center gap-2 text-sm">
|
|
{polling ? (
|
|
<>
|
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
|
Waiting for new MCP logs...
|
|
</>
|
|
) : (
|
|
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={loading} data-testid="mcp-logs-table-refresh-btn">
|
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
{table.getRowModel().rows.length ? (
|
|
table.getRowModel().rows.map((row) => (
|
|
<TableRow key={row.id} className="hover:bg-muted/50 group/table-row h-12 cursor-pointer">
|
|
{row.getVisibleCells().map((cell) => {
|
|
const pinned = cell.column.getIsPinned();
|
|
const size = cell.column.getSize();
|
|
return (
|
|
<TableCell
|
|
onClick={() => onRowClick?.(row.original, cell.column.id)}
|
|
key={cell.id}
|
|
style={{ width: size, minWidth: size, maxWidth: size, ...buildPinStyle(cell.column, pinOffsets) }}
|
|
className={cn(
|
|
"overflow-hidden",
|
|
pinned && "bg-card",
|
|
cell.column.id === lastLeftPinId && PIN_SHADOW_LEFT,
|
|
cell.column.id === firstRightPinId && PIN_SHADOW_RIGHT,
|
|
"group-hover/table-row:bg-[#f7f7f7] dark:group-hover/table-row:bg-[#232327]",
|
|
)}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
No results found. Try adjusting your filters and/or time range.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{/* Pagination Footer */}
|
|
<div className="flex items-center justify-between text-xs" data-testid="pagination">
|
|
<div className="text-muted-foreground flex items-center gap-2">
|
|
{startItemDisplay.toLocaleString()}-{endItemDisplay.toLocaleString()} of {totalItems.toLocaleString()} entries
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => goToPage(currentPage - 1)}
|
|
disabled={currentPage <= 1}
|
|
data-testid="prev-page"
|
|
aria-label="Previous page"
|
|
>
|
|
<ChevronLeft className="size-3" />
|
|
</Button>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<span>Page</span>
|
|
<span>{currentPage}</span>
|
|
<span>of {totalPages}</span>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => goToPage(currentPage + 1)}
|
|
disabled={totalPages === 0 || currentPage >= totalPages}
|
|
data-testid="next-page"
|
|
aria-label="Next page"
|
|
>
|
|
<ChevronRight className="size-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |