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
This commit is contained in:
Sline 2026-01-25 14:49:10 +08:00 committed by GitHub
parent 440f95f617
commit b921098182
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 162 additions and 233 deletions

View File

@ -21,20 +21,14 @@ import {
ListItem, ListItem,
ListItemText, ListItemText,
} from "@mui/material"; } from "@mui/material";
import type { Column } from "@tanstack/react-table";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface ColumnOption {
field: string;
label: string;
visible: boolean;
}
interface Props { interface Props {
open: boolean; open: boolean;
columns: ColumnOption[]; columns: Column<IConnectionsItem, unknown>[];
onClose: () => void; onClose: () => void;
onToggle: (field: string, visible: boolean) => void;
onOrderChange: (order: string[]) => void; onOrderChange: (order: string[]) => void;
onReset: () => void; onReset: () => void;
} }
@ -43,7 +37,6 @@ export const ConnectionColumnManager = ({
open, open,
columns, columns,
onClose, onClose,
onToggle,
onOrderChange, onOrderChange,
onReset, onReset,
}: Props) => { }: Props) => {
@ -54,9 +47,9 @@ export const ConnectionColumnManager = ({
); );
const { t } = useTranslation(); const { t } = useTranslation();
const items = useMemo(() => columns.map((column) => column.field), [columns]); const items = useMemo(() => columns.map((column) => column.id), [columns]);
const visibleCount = useMemo( const visibleCount = useMemo(
() => columns.filter((column) => column.visible).length, () => columns.filter((column) => column.getIsVisible()).length,
[columns], [columns],
); );
@ -65,7 +58,7 @@ export const ConnectionColumnManager = ({
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; 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 oldIndex = order.indexOf(active.id as string);
const newIndex = order.indexOf(over.id as string); const newIndex = order.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return; if (oldIndex === -1 || newIndex === -1) return;
@ -94,13 +87,16 @@ export const ConnectionColumnManager = ({
> >
{columns.map((column) => ( {columns.map((column) => (
<SortableColumnItem <SortableColumnItem
key={column.field} key={column.id}
column={column} column={column}
onToggle={onToggle} label={getColumnLabel(column)}
dragHandleLabel={t( dragHandleLabel={t(
"connections.components.columnManager.dragHandle", "connections.components.columnManager.dragHandle",
)} )}
disableToggle={column.visible && visibleCount <= 1} disableToggle={
!column.getCanHide() ||
(column.getIsVisible() && visibleCount <= 1)
}
/> />
))} ))}
</List> </List>
@ -120,15 +116,15 @@ export const ConnectionColumnManager = ({
}; };
interface SortableColumnItemProps { interface SortableColumnItemProps {
column: ColumnOption; column: Column<IConnectionsItem, unknown>;
onToggle: (field: string, visible: boolean) => void; label: string;
dragHandleLabel: string; dragHandleLabel: string;
disableToggle?: boolean; disableToggle?: boolean;
} }
const SortableColumnItem = ({ const SortableColumnItem = ({
column, column,
onToggle, label,
dragHandleLabel, dragHandleLabel,
disableToggle = false, disableToggle = false,
}: SortableColumnItemProps) => { }: SortableColumnItemProps) => {
@ -139,7 +135,7 @@ const SortableColumnItem = ({
transform, transform,
transition, transition,
isDragging, isDragging,
} = useSortable({ id: column.field }); } = useSortable({ id: column.id });
const style = useMemo( const style = useMemo(
() => ({ () => ({
@ -167,12 +163,12 @@ const SortableColumnItem = ({
> >
<Checkbox <Checkbox
edge="start" edge="start"
checked={column.visible} checked={column.getIsVisible()}
disabled={disableToggle} disabled={disableToggle}
onChange={(event) => onToggle(column.field, event.target.checked)} onChange={(event) => column.toggleVisibility(event.target.checked)}
/> />
<ListItemText <ListItemText
primary={column.label} primary={label}
slotProps={{ primary: { variant: "body2" } }} slotProps={{ primary: { variant: "body2" } }}
sx={{ mr: 1 }} sx={{ mr: 1 }}
/> />
@ -189,3 +185,11 @@ const SortableColumnItem = ({
</ListItem> </ListItem>
); );
}; };
const getColumnLabel = (column: Column<IConnectionsItem, unknown>) => {
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;
};

View File

@ -2,6 +2,7 @@ import { ViewColumnRounded } from "@mui/icons-material";
import { Box, IconButton, Tooltip } from "@mui/material"; import { Box, IconButton, Tooltip } from "@mui/material";
import { import {
ColumnDef, ColumnDef,
ColumnOrderState,
ColumnSizingState, ColumnSizingState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@ -43,50 +44,57 @@ const reconcileColumnOrder = (
return [...filtered, ...missing]; 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 { 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 { switch (field) {
id: each.id, case "host":
host: metadata.host return metadata.host
? `${metadata.host}:${metadata.destinationPort}` ? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`, : `${metadata.remoteDestination}:${metadata.destinationPort}`;
download: each.download, case "download":
upload: each.upload, return each.download;
dlSpeed: each.curDownload, case "upload":
ulSpeed: each.curUpload, return each.upload;
chains, case "dlSpeed":
rule, return each.curDownload;
process: truncateStr(metadata.process || metadata.processPath), case "ulSpeed":
time: each.start, return each.curUpload;
source: `${metadata.sourceIP}:${metadata.sourcePort}`, case "chains":
remoteDestination: destination, return [...each.chains].reverse().join(" / ");
type: `${metadata.type}(${metadata.network})`, case "rule":
connectionData: each, 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<typeof createConnectionRow>;
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 { interface Props {
connections: IConnectionsItem[]; connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void; onShowDetail: (data: IConnectionsItem) => void;
@ -104,33 +112,30 @@ export const ConnectionTable = (props: Props) => {
onCloseColumnManager, onCloseColumnManager,
} = props; } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [columnWidths, setColumnWidths] = useLocalStorage< const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>(
Record<string, number>
>(
"connection-table-widths", "connection-table-widths",
// server-side value, this is the default value used by server-side rendering (if any) // 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) // Do not omit (otherwise a Suspense boundary will be triggered)
{}, {},
); );
const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage< const [columnVisibilityModel, setColumnVisibilityModel] =
Partial<Record<string, boolean>> useLocalStorage<VisibilityState>(
>( "connection-table-visibility",
"connection-table-visibility", {},
{}, {
{ serializer: JSON.stringify,
serializer: JSON.stringify, deserializer: (value) => {
deserializer: (value) => { try {
try { const parsed = JSON.parse(value);
const parsed = JSON.parse(value); if (parsed && typeof parsed === "object") return parsed;
if (parsed && typeof parsed === "object") return parsed; } catch (err) {
} catch (err) { console.warn("Failed to parse connection-table-visibility", err);
console.warn("Failed to parse connection-table-visibility", err); }
} return {};
return {}; },
}, },
}, );
);
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>( const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
"connection-table-order", "connection-table-order",
@ -149,15 +154,13 @@ export const ConnectionTable = (props: Props) => {
}, },
); );
type ColumnField = Exclude<keyof ConnectionRow, "connectionData">;
interface BaseColumn { interface BaseColumn {
field: ColumnField; field: ColumnField;
headerName: string; headerName: string;
width?: number; width?: number;
minWidth?: number; minWidth?: number;
align?: "left" | "right"; align?: "left" | "right";
cell?: (row: ConnectionRow) => ReactNode; cell?: (row: IConnectionsItem) => ReactNode;
} }
const baseColumns = useMemo<BaseColumn[]>(() => { const baseColumns = useMemo<BaseColumn[]>(() => {
@ -190,7 +193,7 @@ export const ConnectionTable = (props: Props) => {
width: 76, width: 76,
minWidth: 60, minWidth: 60,
align: "right", align: "right",
cell: (row) => `${parseTraffic(row.dlSpeed).join(" ")}/s`, cell: (row) => `${parseTraffic(row.curDownload).join(" ")}/s`,
}, },
{ {
field: "ulSpeed", field: "ulSpeed",
@ -198,7 +201,7 @@ export const ConnectionTable = (props: Props) => {
width: 76, width: 76,
minWidth: 60, minWidth: 60,
align: "right", align: "right",
cell: (row) => `${parseTraffic(row.ulSpeed).join(" ")}/s`, cell: (row) => `${parseTraffic(row.curUpload).join(" ")}/s`,
}, },
{ {
field: "chains", field: "chains",
@ -262,177 +265,76 @@ export const ConnectionTable = (props: Props) => {
}); });
}, [baseColumns, setColumnOrder]); }, [baseColumns, setColumnOrder]);
const columns = useMemo<BaseColumn[]>(() => { const handleColumnVisibilityChange = useCallback(
const order = Array.isArray(columnOrder) ? columnOrder : []; (update: Updater<VisibilityState>) => {
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;
}
setColumnVisibilityModel((prev) => { setColumnVisibilityModel((prev) => {
const next = { ...(prev ?? {}) }; const current = prev ?? {};
if (visible) { const nextState =
delete next[field]; typeof update === "function" ? update(current) : update;
} else {
next[field] = false; 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( const handleColumnOrderChange = useCallback(
(order: string[]) => { (update: Updater<ColumnOrderState>) => {
setColumnOrder(() => { setColumnOrder((prev) => {
const current = Array.isArray(prev) ? prev : [];
const nextState =
typeof update === "function" ? update(current) : update;
const baseFields = baseColumns.map((col) => col.field); const baseFields = baseColumns.map((col) => col.field);
return reconcileColumnOrder(order, baseFields); return reconcileColumnOrder(nextState, baseFields);
}); });
}, },
[baseColumns, setColumnOrder], [baseColumns, setColumnOrder],
); );
const handleResetColumns = useCallback(() => {
setColumnVisibilityModel({});
setColumnOrder(baseColumns.map((col) => col.field));
}, [baseColumns, setColumnOrder, setColumnVisibilityModel]);
const handleColumnVisibilityChange = useCallback(
(update: Updater<VisibilityState>) => {
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<Record<string, boolean>> = {};
hiddenFields.forEach((field) => {
sanitized[field] = false;
});
return sanitized;
});
},
[columns, setColumnVisibilityModel],
);
const columnVisibilityState = useMemo<VisibilityState>(() => {
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<Map<string, ConnectionRow>>(new Map());
const connRows = useMemo<ConnectionRow[]>(() => {
const prevMap = prevRowsRef.current;
const nextMap = new Map<string, ConnectionRow>();
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<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [relativeNow, setRelativeNow] = useState(() => Date.now()); const [relativeNow, setRelativeNow] = useState(() => Date.now());
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => { const columnDefs = useMemo<ColumnDef<IConnectionsItem>[]>(() => {
return columns.map((column) => { return baseColumns.map((column) => {
const baseCell: ColumnDef<ConnectionRow>["cell"] = column.cell const baseCell: ColumnDef<IConnectionsItem>["cell"] = column.cell
? (ctx) => column.cell?.(ctx.row.original) ? (ctx) => column.cell?.(ctx.row.original)
: (ctx) => ctx.getValue() as ReactNode; : (ctx) => ctx.getValue() as ReactNode;
const cell: ColumnDef<ConnectionRow>["cell"] = const cell: ColumnDef<IConnectionsItem>["cell"] =
column.field === "time" column.field === "time"
? (ctx) => dayjs(ctx.row.original.time).from(relativeNow) ? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow)
: baseCell; : baseCell;
return { return {
id: column.field, id: column.field,
accessorKey: column.field, accessorFn: (row) => getConnectionCellValue(column.field, row),
header: column.headerName, header: column.headerName,
size: column.width, size: column.width,
minSize: column.minWidth ?? 80, minSize: column.minWidth,
enableResizing: true,
meta: { meta: {
align: column.align ?? "left", align: column.align ?? "left",
field: column.field, field: column.field,
label: column.headerName,
}, },
cell, cell,
} satisfies ColumnDef<ConnectionRow>; } satisfies ColumnDef<IConnectionsItem>;
}); });
}, [columns, relativeNow]); }, [baseColumns, relativeNow]);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return undefined; if (typeof window === "undefined") return undefined;
@ -450,7 +352,7 @@ export const ConnectionTable = (props: Props) => {
const prevState = prev ?? {}; const prevState = prev ?? {};
const nextState = const nextState =
typeof updater === "function" ? updater(prevState) : updater; typeof updater === "function" ? updater(prevState) : updater;
const sanitized: Record<string, number> = {}; const sanitized: ColumnSizingState = {};
Object.entries(nextState).forEach(([key, size]) => { Object.entries(nextState).forEach(([key, size]) => {
if (typeof size === "number" && Number.isFinite(size)) { if (typeof size === "number" && Number.isFinite(size)) {
sanitized[key] = size; sanitized[key] = size;
@ -463,22 +365,45 @@ export const ConnectionTable = (props: Props) => {
); );
const table = useReactTable({ const table = useReactTable({
data: connRows, data: connections,
state: { state: {
columnVisibility: columnVisibilityState, columnVisibility: columnVisibilityModel ?? {},
columnSizing: columnWidths, columnSizing: columnWidths,
columnOrder,
sorting, sorting,
}, },
initialState: {
columnOrder: baseColumns.map((col) => col.field),
},
defaultColumn: {
minSize: 80,
enableResizing: true,
},
columnResizeMode: "onChange", columnResizeMode: "onChange",
enableSortingRemoval: true, enableSortingRemoval: true,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: sorting.length ? getSortedRowModel() : undefined, getSortedRowModel: sorting.length ? getSortedRowModel() : undefined,
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnSizingChange: handleColumnSizingChange, onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: handleColumnVisibilityChange, onColumnVisibilityChange: handleColumnVisibilityChange,
onColumnOrderChange: handleColumnOrderChange,
columns: columnDefs, 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 rows = table.getRowModel().rows;
const tableContainerRef = useRef<HTMLDivElement | null>(null); const tableContainerRef = useRef<HTMLDivElement | null>(null);
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
@ -491,6 +416,7 @@ export const ConnectionTable = (props: Props) => {
const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize(); const totalSize = rowVirtualizer.getTotalSize();
const tableWidth = table.getTotalSize(); const tableWidth = table.getTotalSize();
const managerColumns = table.getAllLeafColumns();
return ( return (
<> <>
@ -669,7 +595,7 @@ export const ConnectionTable = (props: Props) => {
return ( return (
<Box <Box
key={row.id} key={row.id}
onClick={() => onShowDetail(row.original.connectionData)} onClick={() => onShowDetail(row.original)}
sx={{ sx={{
display: "flex", display: "flex",
position: "absolute", position: "absolute",
@ -726,9 +652,8 @@ export const ConnectionTable = (props: Props) => {
</Box> </Box>
<ConnectionColumnManager <ConnectionColumnManager
open={columnManagerOpen} open={columnManagerOpen}
columns={columnOptions} columns={managerColumns}
onClose={onCloseColumnManager} onClose={onCloseColumnManager}
onToggle={handleToggleColumn}
onOrderChange={handleManagerOrderChange} onOrderChange={handleManagerOrderChange}
onReset={handleResetColumns} onReset={handleResetColumns}
/> />