From b9210981825938d1896a726c4bbe3f4d0cd919c7 Mon Sep 17 00:00:00 2001 From: Sline Date: Sun, 25 Jan 2026 14:49:10 +0800 Subject: [PATCH] refactor(connections): switch manager table to TanStack column accessors and IConnectionsItem rows (#6083) * refactor(connection-table): drive column order/visibility/sorting by TanStack Table state * refactor(connection-table): simplify table data flow and align with built-in API * refactor(connection-table): let column manager consume TanStack Table columns directly --- .../connection/connection-column-manager.tsx | 48 +-- .../connection/connection-table.tsx | 347 +++++++----------- 2 files changed, 162 insertions(+), 233 deletions(-) diff --git a/src/components/connection/connection-column-manager.tsx b/src/components/connection/connection-column-manager.tsx index f2a5a6e42..19a182d63 100644 --- a/src/components/connection/connection-column-manager.tsx +++ b/src/components/connection/connection-column-manager.tsx @@ -21,20 +21,14 @@ import { ListItem, ListItemText, } from "@mui/material"; +import type { Column } from "@tanstack/react-table"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -interface ColumnOption { - field: string; - label: string; - visible: boolean; -} - interface Props { open: boolean; - columns: ColumnOption[]; + columns: Column[]; onClose: () => void; - onToggle: (field: string, visible: boolean) => void; onOrderChange: (order: string[]) => void; onReset: () => void; } @@ -43,7 +37,6 @@ export const ConnectionColumnManager = ({ open, columns, onClose, - onToggle, onOrderChange, onReset, }: Props) => { @@ -54,9 +47,9 @@ export const ConnectionColumnManager = ({ ); const { t } = useTranslation(); - const items = useMemo(() => columns.map((column) => column.field), [columns]); + const items = useMemo(() => columns.map((column) => column.id), [columns]); const visibleCount = useMemo( - () => columns.filter((column) => column.visible).length, + () => columns.filter((column) => column.getIsVisible()).length, [columns], ); @@ -65,7 +58,7 @@ export const ConnectionColumnManager = ({ const { active, over } = event; if (!over || active.id === over.id) return; - const order = columns.map((column) => column.field); + const order = columns.map((column) => column.id); const oldIndex = order.indexOf(active.id as string); const newIndex = order.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; @@ -94,13 +87,16 @@ export const ConnectionColumnManager = ({ > {columns.map((column) => ( ))} @@ -120,15 +116,15 @@ export const ConnectionColumnManager = ({ }; interface SortableColumnItemProps { - column: ColumnOption; - onToggle: (field: string, visible: boolean) => void; + column: Column; + label: string; dragHandleLabel: string; disableToggle?: boolean; } const SortableColumnItem = ({ column, - onToggle, + label, dragHandleLabel, disableToggle = false, }: SortableColumnItemProps) => { @@ -139,7 +135,7 @@ const SortableColumnItem = ({ transform, transition, isDragging, - } = useSortable({ id: column.field }); + } = useSortable({ id: column.id }); const style = useMemo( () => ({ @@ -167,12 +163,12 @@ const SortableColumnItem = ({ > onToggle(column.field, event.target.checked)} + onChange={(event) => column.toggleVisibility(event.target.checked)} /> @@ -189,3 +185,11 @@ const SortableColumnItem = ({ ); }; + +const getColumnLabel = (column: Column) => { + const meta = column.columnDef.meta as { label?: string } | undefined; + if (meta?.label) return meta.label; + + const header = column.columnDef.header; + return typeof header === "string" ? header : column.id; +}; diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index b9d184a2e..f121f2720 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -2,6 +2,7 @@ import { ViewColumnRounded } from "@mui/icons-material"; import { Box, IconButton, Tooltip } from "@mui/material"; import { ColumnDef, + ColumnOrderState, ColumnSizingState, flexRender, getCoreRowModel, @@ -43,50 +44,57 @@ const reconcileColumnOrder = ( return [...filtered, ...missing]; }; -const createConnectionRow = (each: IConnectionsItem) => { +type ColumnField = + | "host" + | "download" + | "upload" + | "dlSpeed" + | "ulSpeed" + | "chains" + | "rule" + | "process" + | "time" + | "source" + | "remoteDestination" + | "type"; + +const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => { const { metadata, rulePayload } = each; - const chains = [...each.chains].reverse().join(" / "); - const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule; - const destination = metadata.destinationIP - ? `${metadata.destinationIP}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}`; - return { - id: each.id, - host: metadata.host - ? `${metadata.host}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}`, - download: each.download, - upload: each.upload, - dlSpeed: each.curDownload, - ulSpeed: each.curUpload, - chains, - rule, - process: truncateStr(metadata.process || metadata.processPath), - time: each.start, - source: `${metadata.sourceIP}:${metadata.sourcePort}`, - remoteDestination: destination, - type: `${metadata.type}(${metadata.network})`, - connectionData: each, - }; + switch (field) { + case "host": + return metadata.host + ? `${metadata.host}:${metadata.destinationPort}` + : `${metadata.remoteDestination}:${metadata.destinationPort}`; + case "download": + return each.download; + case "upload": + return each.upload; + case "dlSpeed": + return each.curDownload; + case "ulSpeed": + return each.curUpload; + case "chains": + return [...each.chains].reverse().join(" / "); + case "rule": + return rulePayload ? `${each.rule}(${rulePayload})` : each.rule; + case "process": + return truncateStr(metadata.process || metadata.processPath); + case "time": + return each.start; + case "source": + return `${metadata.sourceIP}:${metadata.sourcePort}`; + case "remoteDestination": + return metadata.destinationIP + ? `${metadata.destinationIP}:${metadata.destinationPort}` + : `${metadata.remoteDestination}:${metadata.destinationPort}`; + case "type": + return `${metadata.type}(${metadata.network})`; + default: + return ""; + } }; -type ConnectionRow = ReturnType; - -const areRowsEqual = (a: ConnectionRow, b: ConnectionRow) => - a.host === b.host && - a.download === b.download && - a.upload === b.upload && - a.dlSpeed === b.dlSpeed && - a.ulSpeed === b.ulSpeed && - a.chains === b.chains && - a.rule === b.rule && - a.process === b.process && - a.time === b.time && - a.source === b.source && - a.remoteDestination === b.remoteDestination && - a.type === b.type; - interface Props { connections: IConnectionsItem[]; onShowDetail: (data: IConnectionsItem) => void; @@ -104,33 +112,30 @@ export const ConnectionTable = (props: Props) => { onCloseColumnManager, } = props; const { t } = useTranslation(); - const [columnWidths, setColumnWidths] = useLocalStorage< - Record - >( + const [columnWidths, setColumnWidths] = useLocalStorage( "connection-table-widths", // server-side value, this is the default value used by server-side rendering (if any) // Do not omit (otherwise a Suspense boundary will be triggered) {}, ); - const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage< - Partial> - >( - "connection-table-visibility", - {}, - { - serializer: JSON.stringify, - deserializer: (value) => { - try { - const parsed = JSON.parse(value); - if (parsed && typeof parsed === "object") return parsed; - } catch (err) { - console.warn("Failed to parse connection-table-visibility", err); - } - return {}; + const [columnVisibilityModel, setColumnVisibilityModel] = + useLocalStorage( + "connection-table-visibility", + {}, + { + serializer: JSON.stringify, + deserializer: (value) => { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object") return parsed; + } catch (err) { + console.warn("Failed to parse connection-table-visibility", err); + } + return {}; + }, }, - }, - ); + ); const [columnOrder, setColumnOrder] = useLocalStorage( "connection-table-order", @@ -149,15 +154,13 @@ export const ConnectionTable = (props: Props) => { }, ); - type ColumnField = Exclude; - interface BaseColumn { field: ColumnField; headerName: string; width?: number; minWidth?: number; align?: "left" | "right"; - cell?: (row: ConnectionRow) => ReactNode; + cell?: (row: IConnectionsItem) => ReactNode; } const baseColumns = useMemo(() => { @@ -190,7 +193,7 @@ export const ConnectionTable = (props: Props) => { width: 76, minWidth: 60, align: "right", - cell: (row) => `${parseTraffic(row.dlSpeed).join(" ")}/s`, + cell: (row) => `${parseTraffic(row.curDownload).join(" ")}/s`, }, { field: "ulSpeed", @@ -198,7 +201,7 @@ export const ConnectionTable = (props: Props) => { width: 76, minWidth: 60, align: "right", - cell: (row) => `${parseTraffic(row.ulSpeed).join(" ")}/s`, + cell: (row) => `${parseTraffic(row.curUpload).join(" ")}/s`, }, { field: "chains", @@ -262,177 +265,76 @@ export const ConnectionTable = (props: Props) => { }); }, [baseColumns, setColumnOrder]); - const columns = useMemo(() => { - const order = Array.isArray(columnOrder) ? columnOrder : []; - const orderMap = new Map(order.map((field, index) => [field, index])); - - return [...baseColumns].sort((a, b) => { - const aIndex = orderMap.has(a.field) - ? (orderMap.get(a.field) as number) - : Number.MAX_SAFE_INTEGER; - const bIndex = orderMap.has(b.field) - ? (orderMap.get(b.field) as number) - : Number.MAX_SAFE_INTEGER; - - if (aIndex === bIndex) { - return order.indexOf(a.field) - order.indexOf(b.field); - } - - return aIndex - bIndex; - }); - }, [baseColumns, columnOrder]); - - const visibleColumnsCount = useMemo(() => { - return columns.reduce((count, column) => { - return (columnVisibilityModel?.[column.field] ?? true) !== false - ? count + 1 - : count; - }, 0); - }, [columns, columnVisibilityModel]); - - const handleToggleColumn = useCallback( - (field: string, visible: boolean) => { - if (!visible && visibleColumnsCount <= 1) { - return; - } - + const handleColumnVisibilityChange = useCallback( + (update: Updater) => { setColumnVisibilityModel((prev) => { - const next = { ...(prev ?? {}) }; - if (visible) { - delete next[field]; - } else { - next[field] = false; + const current = prev ?? {}; + const nextState = + typeof update === "function" ? update(current) : update; + + const visibleCount = baseColumns.reduce((count, column) => { + const isVisible = (nextState[column.field] ?? true) !== false; + return count + (isVisible ? 1 : 0); + }, 0); + + if (visibleCount === 0) { + return current; } - return next; + + const sanitized: VisibilityState = {}; + baseColumns.forEach((column) => { + if (nextState[column.field] === false) { + sanitized[column.field] = false; + } + }); + return sanitized; }); }, - [setColumnVisibilityModel, visibleColumnsCount], + [baseColumns, setColumnVisibilityModel], ); - const handleManagerOrderChange = useCallback( - (order: string[]) => { - setColumnOrder(() => { + const handleColumnOrderChange = useCallback( + (update: Updater) => { + setColumnOrder((prev) => { + const current = Array.isArray(prev) ? prev : []; + const nextState = + typeof update === "function" ? update(current) : update; const baseFields = baseColumns.map((col) => col.field); - return reconcileColumnOrder(order, baseFields); + return reconcileColumnOrder(nextState, baseFields); }); }, [baseColumns, setColumnOrder], ); - const handleResetColumns = useCallback(() => { - setColumnVisibilityModel({}); - setColumnOrder(baseColumns.map((col) => col.field)); - }, [baseColumns, setColumnOrder, setColumnVisibilityModel]); - - const handleColumnVisibilityChange = useCallback( - (update: Updater) => { - setColumnVisibilityModel((prev) => { - const current = prev ?? {}; - const baseState: VisibilityState = {}; - columns.forEach((column) => { - baseState[column.field] = (current[column.field] ?? true) !== false; - }); - - const mergedState = - typeof update === "function" - ? update(baseState) - : { ...baseState, ...update }; - - const hiddenFields = columns - .filter((column) => mergedState[column.field] === false) - .map((column) => column.field); - - if (columns.length - hiddenFields.length === 0) { - return current; - } - - const sanitized: Partial> = {}; - hiddenFields.forEach((field) => { - sanitized[field] = false; - }); - return sanitized; - }); - }, - [columns, setColumnVisibilityModel], - ); - - const columnVisibilityState = useMemo(() => { - const result: VisibilityState = {}; - if (!columnVisibilityModel) { - columns.forEach((column) => { - result[column.field] = true; - }); - return result; - } - - columns.forEach((column) => { - result[column.field] = - (columnVisibilityModel?.[column.field] ?? true) !== false; - }); - - return result; - }, [columnVisibilityModel, columns]); - - const columnOptions = useMemo(() => { - return columns.map((column) => ({ - field: column.field, - label: column.headerName ?? column.field, - visible: (columnVisibilityModel?.[column.field] ?? true) !== false, - })); - }, [columns, columnVisibilityModel]); - - const prevRowsRef = useRef>(new Map()); - - const connRows = useMemo(() => { - const prevMap = prevRowsRef.current; - const nextMap = new Map(); - - const nextRows = connections.map((each) => { - const nextRow = createConnectionRow(each); - const prevRow = prevMap.get(each.id); - - if (prevRow && areRowsEqual(prevRow, nextRow)) { - nextMap.set(each.id, prevRow); - return prevRow; - } - - nextMap.set(each.id, nextRow); - return nextRow; - }); - - prevRowsRef.current = nextMap; - return nextRows; - }, [connections]); - const [sorting, setSorting] = useState([]); const [relativeNow, setRelativeNow] = useState(() => Date.now()); - const columnDefs = useMemo[]>(() => { - return columns.map((column) => { - const baseCell: ColumnDef["cell"] = column.cell + const columnDefs = useMemo[]>(() => { + return baseColumns.map((column) => { + const baseCell: ColumnDef["cell"] = column.cell ? (ctx) => column.cell?.(ctx.row.original) : (ctx) => ctx.getValue() as ReactNode; - const cell: ColumnDef["cell"] = + const cell: ColumnDef["cell"] = column.field === "time" - ? (ctx) => dayjs(ctx.row.original.time).from(relativeNow) + ? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow) : baseCell; return { id: column.field, - accessorKey: column.field, + accessorFn: (row) => getConnectionCellValue(column.field, row), header: column.headerName, size: column.width, - minSize: column.minWidth ?? 80, - enableResizing: true, + minSize: column.minWidth, meta: { align: column.align ?? "left", field: column.field, + label: column.headerName, }, cell, - } satisfies ColumnDef; + } satisfies ColumnDef; }); - }, [columns, relativeNow]); + }, [baseColumns, relativeNow]); useEffect(() => { if (typeof window === "undefined") return undefined; @@ -450,7 +352,7 @@ export const ConnectionTable = (props: Props) => { const prevState = prev ?? {}; const nextState = typeof updater === "function" ? updater(prevState) : updater; - const sanitized: Record = {}; + const sanitized: ColumnSizingState = {}; Object.entries(nextState).forEach(([key, size]) => { if (typeof size === "number" && Number.isFinite(size)) { sanitized[key] = size; @@ -463,22 +365,45 @@ export const ConnectionTable = (props: Props) => { ); const table = useReactTable({ - data: connRows, + data: connections, state: { - columnVisibility: columnVisibilityState, + columnVisibility: columnVisibilityModel ?? {}, columnSizing: columnWidths, + columnOrder, sorting, }, + initialState: { + columnOrder: baseColumns.map((col) => col.field), + }, + defaultColumn: { + minSize: 80, + enableResizing: true, + }, columnResizeMode: "onChange", enableSortingRemoval: true, + getRowId: (row) => row.id, getCoreRowModel: getCoreRowModel(), getSortedRowModel: sorting.length ? getSortedRowModel() : undefined, onSortingChange: setSorting, onColumnSizingChange: handleColumnSizingChange, onColumnVisibilityChange: handleColumnVisibilityChange, + onColumnOrderChange: handleColumnOrderChange, columns: columnDefs, }); + const handleManagerOrderChange = useCallback( + (order: string[]) => { + const baseFields = baseColumns.map((col) => col.field); + table.setColumnOrder(reconcileColumnOrder(order, baseFields)); + }, + [baseColumns, table], + ); + + const handleResetColumns = useCallback(() => { + table.resetColumnVisibility(); + table.resetColumnOrder(); + }, [table]); + const rows = table.getRowModel().rows; const tableContainerRef = useRef(null); const rowVirtualizer = useVirtualizer({ @@ -491,6 +416,7 @@ export const ConnectionTable = (props: Props) => { const virtualRows = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); const tableWidth = table.getTotalSize(); + const managerColumns = table.getAllLeafColumns(); return ( <> @@ -669,7 +595,7 @@ export const ConnectionTable = (props: Props) => { return ( onShowDetail(row.original.connectionData)} + onClick={() => onShowDetail(row.original)} sx={{ display: "flex", position: "absolute", @@ -726,9 +652,8 @@ export const ConnectionTable = (props: Props) => {