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
2 changed files with 162 additions and 233 deletions

View File

@@ -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<IConnectionsItem, unknown>[];
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) => (
<SortableColumnItem
key={column.field}
key={column.id}
column={column}
onToggle={onToggle}
label={getColumnLabel(column)}
dragHandleLabel={t(
"connections.components.columnManager.dragHandle",
)}
disableToggle={column.visible && visibleCount <= 1}
disableToggle={
!column.getCanHide() ||
(column.getIsVisible() && visibleCount <= 1)
}
/>
))}
</List>
@@ -120,15 +116,15 @@ export const ConnectionColumnManager = ({
};
interface SortableColumnItemProps {
column: ColumnOption;
onToggle: (field: string, visible: boolean) => void;
column: Column<IConnectionsItem, unknown>;
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 = ({
>
<Checkbox
edge="start"
checked={column.visible}
checked={column.getIsVisible()}
disabled={disableToggle}
onChange={(event) => onToggle(column.field, event.target.checked)}
onChange={(event) => column.toggleVisibility(event.target.checked)}
/>
<ListItemText
primary={column.label}
primary={label}
slotProps={{ primary: { variant: "body2" } }}
sx={{ mr: 1 }}
/>
@@ -189,3 +185,11 @@ const SortableColumnItem = ({
</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 {
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<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 {
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<string, number>
>(
const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>(
"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<Record<string, boolean>>
>(
"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<VisibilityState>(
"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<string[]>(
"connection-table-order",
@@ -149,15 +154,13 @@ export const ConnectionTable = (props: Props) => {
},
);
type ColumnField = Exclude<keyof ConnectionRow, "connectionData">;
interface BaseColumn {
field: ColumnField;
headerName: string;
width?: number;
minWidth?: number;
align?: "left" | "right";
cell?: (row: ConnectionRow) => ReactNode;
cell?: (row: IConnectionsItem) => ReactNode;
}
const baseColumns = useMemo<BaseColumn[]>(() => {
@@ -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<BaseColumn[]>(() => {
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<VisibilityState>) => {
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<ColumnOrderState>) => {
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<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 [relativeNow, setRelativeNow] = useState(() => Date.now());
const columnDefs = useMemo<ColumnDef<ConnectionRow>[]>(() => {
return columns.map((column) => {
const baseCell: ColumnDef<ConnectionRow>["cell"] = column.cell
const columnDefs = useMemo<ColumnDef<IConnectionsItem>[]>(() => {
return baseColumns.map((column) => {
const baseCell: ColumnDef<IConnectionsItem>["cell"] = column.cell
? (ctx) => column.cell?.(ctx.row.original)
: (ctx) => ctx.getValue() as ReactNode;
const cell: ColumnDef<ConnectionRow>["cell"] =
const cell: ColumnDef<IConnectionsItem>["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<ConnectionRow>;
} satisfies ColumnDef<IConnectionsItem>;
});
}, [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<string, number> = {};
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<HTMLDivElement | null>(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 (
<Box
key={row.id}
onClick={() => onShowDetail(row.original.connectionData)}
onClick={() => onShowDetail(row.original)}
sx={{
display: "flex",
position: "absolute",
@@ -726,9 +652,8 @@ export const ConnectionTable = (props: Props) => {
</Box>
<ConnectionColumnManager
open={columnManagerOpen}
columns={columnOptions}
columns={managerColumns}
onClose={onCloseColumnManager}
onToggle={handleToggleColumn}
onOrderChange={handleManagerOrderChange}
onReset={handleResetColumns}
/>