diff --git a/src/components/connection/connection-column-manager.tsx b/src/components/connection/connection-column-manager.tsx
new file mode 100644
index 000000000..f2a5a6e42
--- /dev/null
+++ b/src/components/connection/connection-column-manager.tsx
@@ -0,0 +1,191 @@
+import {
+ closestCenter,
+ DndContext,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { DragIndicatorRounded } from "@mui/icons-material";
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ IconButton,
+ List,
+ ListItem,
+ ListItemText,
+} from "@mui/material";
+import { useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+
+interface ColumnOption {
+ field: string;
+ label: string;
+ visible: boolean;
+}
+
+interface Props {
+ open: boolean;
+ columns: ColumnOption[];
+ onClose: () => void;
+ onToggle: (field: string, visible: boolean) => void;
+ onOrderChange: (order: string[]) => void;
+ onReset: () => void;
+}
+
+export const ConnectionColumnManager = ({
+ open,
+ columns,
+ onClose,
+ onToggle,
+ onOrderChange,
+ onReset,
+}: Props) => {
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 6 },
+ }),
+ );
+ const { t } = useTranslation();
+
+ const items = useMemo(() => columns.map((column) => column.field), [columns]);
+ const visibleCount = useMemo(
+ () => columns.filter((column) => column.visible).length,
+ [columns],
+ );
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const order = columns.map((column) => column.field);
+ const oldIndex = order.indexOf(active.id as string);
+ const newIndex = order.indexOf(over.id as string);
+ if (oldIndex === -1 || newIndex === -1) return;
+
+ onOrderChange(arrayMove(order, oldIndex, newIndex));
+ },
+ [columns, onOrderChange],
+ );
+
+ return (
+
+ );
+};
+
+interface SortableColumnItemProps {
+ column: ColumnOption;
+ onToggle: (field: string, visible: boolean) => void;
+ dragHandleLabel: string;
+ disableToggle?: boolean;
+}
+
+const SortableColumnItem = ({
+ column,
+ onToggle,
+ dragHandleLabel,
+ disableToggle = false,
+}: SortableColumnItemProps) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: column.field });
+
+ const style = useMemo(
+ () => ({
+ transform: CSS.Transform.toString(transform),
+ transition,
+ }),
+ [transform, transition],
+ );
+
+ return (
+ `1px solid ${theme.palette.divider}`,
+ backgroundColor: isDragging ? "action.hover" : "transparent",
+ display: "flex",
+ alignItems: "center",
+ gap: 1,
+ }}
+ style={style}
+ >
+ onToggle(column.field, event.target.checked)}
+ />
+
+
+
+
+
+ );
+};
diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx
index 8761a522a..f1eebb535 100644
--- a/src/components/connection/connection-table.tsx
+++ b/src/components/connection/connection-table.tsx
@@ -1,24 +1,63 @@
+import { Box } from "@mui/material";
import {
DataGrid,
GridColDef,
+ GridColumnOrderChangeParams,
GridColumnResizeParams,
+ GridColumnVisibilityModel,
useGridApiRef,
+ GridColumnMenuItemProps,
+ GridColumnMenuHideItem,
+ useGridRootProps,
} from "@mui/x-data-grid";
import dayjs from "dayjs";
import { useLocalStorage } from "foxact/use-local-storage";
-import { useLayoutEffect, useMemo, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ createContext,
+ use,
+} from "react";
+import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import parseTraffic from "@/utils/parse-traffic";
import { truncateStr } from "@/utils/truncate-str";
+import { ConnectionColumnManager } from "./connection-column-manager";
+
+const ColumnManagerContext = createContext<() => void>(() => {});
+
+/**
+ * Reconcile stored column order with base columns to handle added/removed fields
+ */
+const reconcileColumnOrder = (
+ storedOrder: string[],
+ baseFields: string[],
+): string[] => {
+ const filtered = storedOrder.filter((field) => baseFields.includes(field));
+ const missing = baseFields.filter((field) => !filtered.includes(field));
+ return [...filtered, ...missing];
+};
+
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
+ columnManagerOpen: boolean;
+ onOpenColumnManager: () => void;
+ onCloseColumnManager: () => void;
}
export const ConnectionTable = (props: Props) => {
- const { connections, onShowDetail } = props;
+ const {
+ connections,
+ onShowDetail,
+ columnManagerOpen,
+ onOpenColumnManager,
+ onCloseColumnManager,
+ } = props;
const { t } = useTranslation();
const apiRef = useGridApiRef();
useLayoutEffect(() => {
@@ -145,10 +184,6 @@ export const ConnectionTable = (props: Props) => {
};
}, [apiRef]);
- const [columnVisible, setColumnVisible] = useState<
- Partial>
- >({});
-
const [columnWidths, setColumnWidths] = useLocalStorage<
Record
>(
@@ -158,7 +193,43 @@ export const ConnectionTable = (props: Props) => {
{},
);
- const columns = useMemo(() => {
+ 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 [columnOrder, setColumnOrder] = useLocalStorage(
+ "connection-table-order",
+ [],
+ {
+ serializer: JSON.stringify,
+ deserializer: (value) => {
+ try {
+ const parsed = JSON.parse(value);
+ if (Array.isArray(parsed)) return parsed;
+ } catch (err) {
+ console.warn("Failed to parse connection-table-order", err);
+ }
+ return [];
+ },
+ },
+ );
+
+ const baseColumns = useMemo(() => {
return [
{
field: "host",
@@ -248,6 +319,49 @@ export const ConnectionTable = (props: Props) => {
];
}, [columnWidths, t]);
+ useEffect(() => {
+ setColumnOrder((prevValue) => {
+ const baseFields = baseColumns.map((col) => col.field);
+ const prev = Array.isArray(prevValue) ? prevValue : [];
+ const reconciled = reconcileColumnOrder(prev, baseFields);
+ if (
+ reconciled.length === prev.length &&
+ reconciled.every((field, i) => field === prev[i])
+ ) {
+ return prevValue;
+ }
+ return reconciled;
+ });
+ }, [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 handleColumnResize = (params: GridColumnResizeParams) => {
const { colDef, width } = params;
setColumnWidths((prev) => ({
@@ -256,6 +370,111 @@ export const ConnectionTable = (props: Props) => {
}));
};
+ const handleColumnVisibilityChange = useCallback(
+ (model: GridColumnVisibilityModel) => {
+ const hiddenFields = new Set();
+ Object.entries(model).forEach(([field, value]) => {
+ if (value === false) {
+ hiddenFields.add(field);
+ }
+ });
+
+ const nextVisibleCount = columns.reduce((count, column) => {
+ return hiddenFields.has(column.field) ? count : count + 1;
+ }, 0);
+
+ if (nextVisibleCount === 0) {
+ return;
+ }
+
+ setColumnVisibilityModel(() => {
+ const sanitized: Partial> = {};
+ hiddenFields.forEach((field) => {
+ sanitized[field] = false;
+ });
+ return sanitized;
+ });
+ },
+ [columns, setColumnVisibilityModel],
+ );
+
+ const handleToggleColumn = useCallback(
+ (field: string, visible: boolean) => {
+ if (!visible && visibleColumnsCount <= 1) {
+ return;
+ }
+
+ setColumnVisibilityModel((prev) => {
+ const next = { ...(prev ?? {}) };
+ if (visible) {
+ delete next[field];
+ } else {
+ next[field] = false;
+ }
+ return next;
+ });
+ },
+ [setColumnVisibilityModel, visibleColumnsCount],
+ );
+
+ const handleColumnOrderChange = useCallback(
+ (params: GridColumnOrderChangeParams) => {
+ setColumnOrder((prevValue) => {
+ const baseFields = baseColumns.map((col) => col.field);
+ const currentOrder = Array.isArray(prevValue)
+ ? [...prevValue]
+ : [...baseFields];
+ const field = params.column.field;
+ const currentIndex = currentOrder.indexOf(field);
+ if (currentIndex === -1) return currentOrder;
+
+ currentOrder.splice(currentIndex, 1);
+ const targetIndex = Math.min(
+ Math.max(params.targetIndex, 0),
+ currentOrder.length,
+ );
+ currentOrder.splice(targetIndex, 0, field);
+
+ return currentOrder;
+ });
+ },
+ [baseColumns, setColumnOrder],
+ );
+
+ const handleManagerOrderChange = useCallback(
+ (order: string[]) => {
+ setColumnOrder(() => {
+ const baseFields = baseColumns.map((col) => col.field);
+ return reconcileColumnOrder(order, baseFields);
+ });
+ },
+ [baseColumns, setColumnOrder],
+ );
+
+ const handleResetColumns = useCallback(() => {
+ setColumnVisibilityModel({});
+ setColumnOrder(baseColumns.map((col) => col.field));
+ }, [baseColumns, setColumnOrder, setColumnVisibilityModel]);
+
+ const gridVisibilityModel = useMemo(() => {
+ const result: GridColumnVisibilityModel = {};
+ if (!columnVisibilityModel) return result;
+ Object.entries(columnVisibilityModel).forEach(([field, value]) => {
+ if (typeof value === "boolean") {
+ result[field] = value;
+ }
+ });
+ return result;
+ }, [columnVisibilityModel]);
+
+ const columnOptions = useMemo(() => {
+ return columns.map((column) => ({
+ field: column.field,
+ label: column.headerName ?? column.field,
+ visible: (columnVisibilityModel?.[column.field] ?? true) !== false,
+ }));
+ }, [columns, columnVisibilityModel]);
+
const connRows = useMemo(() => {
return connections.map((each) => {
const { metadata, rulePayload } = each;
@@ -286,24 +505,98 @@ export const ConnectionTable = (props: Props) => {
}, [connections]);
return (
- onShowDetail(e.row.connectionData)}
- density="compact"
- sx={{
- border: "none",
- "div:focus": { outline: "none !important" },
- "& .MuiDataGrid-columnHeader": {
- userSelect: "none",
- },
- }}
- columnVisibilityModel={columnVisible}
- onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
- onColumnResize={handleColumnResize}
- disableColumnMenu={false}
- />
+
+
+ onShowDetail(e.row.connectionData)}
+ density="compact"
+ sx={{
+ flex: 1,
+ border: "none",
+ minHeight: 0,
+ "div:focus": { outline: "none !important" },
+ "& .MuiDataGrid-columnHeader": {
+ userSelect: "none",
+ },
+ }}
+ columnVisibilityModel={gridVisibilityModel}
+ onColumnVisibilityModelChange={handleColumnVisibilityChange}
+ onColumnResize={handleColumnResize}
+ onColumnOrderChange={handleColumnOrderChange}
+ slotProps={{
+ columnMenu: {
+ slots: {
+ columnMenuColumnsItem: ConnectionColumnMenuColumnsItem,
+ },
+ },
+ }}
+ />
+
+
+
+ );
+};
+
+type ConnectionColumnMenuManageItemProps = GridColumnMenuItemProps & {
+ onOpenColumnManager: () => void;
+};
+
+const ConnectionColumnMenuManageItem = (
+ props: ConnectionColumnMenuManageItemProps,
+) => {
+ const { onClick, onOpenColumnManager } = props;
+ const rootProps = useGridRootProps();
+ const { t } = useTranslation();
+ const handleClick = useCallback(
+ (event: MouseEvent) => {
+ onClick(event);
+ onOpenColumnManager();
+ },
+ [onClick, onOpenColumnManager],
+ );
+
+ if (rootProps.disableColumnSelector) {
+ return null;
+ }
+
+ const MenuItem = rootProps.slots.baseMenuItem;
+ const Icon = rootProps.slots.columnMenuManageColumnsIcon;
+
+ return (
+ }>
+ {t("connections.components.columnManager.title")}
+
+ );
+};
+
+const ConnectionColumnMenuColumnsItem = (props: GridColumnMenuItemProps) => {
+ const onOpenColumnManager = use(ColumnManagerContext);
+
+ return (
+ <>
+
+
+ >
);
};
diff --git a/src/locales/ar/connections.json b/src/locales/ar/connections.json
index ad88bf120..be0db7d6e 100644
--- a/src/locales/ar/connections.json
+++ b/src/locales/ar/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "إغلاق الاتصال"
+ },
+ "columnManager": {
+ "title": "الأعمدة",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/de/connections.json b/src/locales/de/connections.json
index 97af29ee3..65d032fcf 100644
--- a/src/locales/de/connections.json
+++ b/src/locales/de/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Verbindung schließen"
+ },
+ "columnManager": {
+ "title": "Spalten",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/en/connections.json b/src/locales/en/connections.json
index 4d5e53605..713a8d114 100644
--- a/src/locales/en/connections.json
+++ b/src/locales/en/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Close Connection"
+ },
+ "columnManager": {
+ "title": "Columns",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/es/connections.json b/src/locales/es/connections.json
index e40c6b526..d398d03fe 100644
--- a/src/locales/es/connections.json
+++ b/src/locales/es/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Cerrar conexión"
+ },
+ "columnManager": {
+ "title": "Columnas",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/fa/connections.json b/src/locales/fa/connections.json
index dfd891c2a..3f7d62f18 100644
--- a/src/locales/fa/connections.json
+++ b/src/locales/fa/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "بستن اتصال"
+ },
+ "columnManager": {
+ "title": "ستونها",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/id/connections.json b/src/locales/id/connections.json
index 1616eeeb0..6b515227a 100644
--- a/src/locales/id/connections.json
+++ b/src/locales/id/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Tutup Koneksi"
+ },
+ "columnManager": {
+ "title": "Kolom",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/jp/connections.json b/src/locales/jp/connections.json
index 806f4bb13..6cde1afbd 100644
--- a/src/locales/jp/connections.json
+++ b/src/locales/jp/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "接続を閉じる"
+ },
+ "columnManager": {
+ "title": "列",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/ko/connections.json b/src/locales/ko/connections.json
index f277d252a..036fa5853 100644
--- a/src/locales/ko/connections.json
+++ b/src/locales/ko/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "연결 닫기"
+ },
+ "columnManager": {
+ "title": "열",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/ru/connections.json b/src/locales/ru/connections.json
index 09d0caf92..24c706fbe 100644
--- a/src/locales/ru/connections.json
+++ b/src/locales/ru/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Закрыть соединение"
+ },
+ "columnManager": {
+ "title": "Столбцы",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/tr/connections.json b/src/locales/tr/connections.json
index e13e2bd18..040cae012 100644
--- a/src/locales/tr/connections.json
+++ b/src/locales/tr/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Bağlantıyı Kapat"
+ },
+ "columnManager": {
+ "title": "Sütunlar",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/tt/connections.json b/src/locales/tt/connections.json
index 847a07f1e..27a2476a9 100644
--- a/src/locales/tt/connections.json
+++ b/src/locales/tt/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "Тоташуны ябу"
+ },
+ "columnManager": {
+ "title": "Баганалар",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/locales/zh/connections.json b/src/locales/zh/connections.json
index 3b4a4ddd2..2e498493f 100644
--- a/src/locales/zh/connections.json
+++ b/src/locales/zh/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "关闭连接"
+ },
+ "columnManager": {
+ "title": "列设置",
+ "dragHandle": "拖拽控件"
}
}
}
diff --git a/src/locales/zhtw/connections.json b/src/locales/zhtw/connections.json
index 6da04e1a5..5acf73055 100644
--- a/src/locales/zhtw/connections.json
+++ b/src/locales/zhtw/connections.json
@@ -23,6 +23,10 @@
},
"actions": {
"closeConnection": "關閉連線"
+ },
+ "columnManager": {
+ "title": "欄位設定",
+ "dragHandle": "Drag handle"
}
}
}
diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx
index 5286b73e2..4d2dce6ae 100644
--- a/src/pages/connections.tsx
+++ b/src/pages/connections.tsx
@@ -86,6 +86,7 @@ const ConnectionsPage = () => {
const [isPaused, setIsPaused] = useState(false);
const [frozenData, setFrozenData] = useState(null);
+ const [isColumnManagerOpen, setIsColumnManagerOpen] = useState(false);
// 使用全局连接数据
const displayData = useMemo(() => {
@@ -110,14 +111,17 @@ const ConnectionsPage = () => {
const [filterConn] = useMemo(() => {
const orderFunc = orderFunctionMap[curOrderOpt];
- let conns = displayData.connections?.filter((conn) => {
- const { host, destinationIP, process } = conn.metadata;
- return (
- match(host || "") || match(destinationIP || "") || match(process || "")
- );
- });
-
- if (orderFunc) conns = orderFunc(conns ?? []);
+ let conns: IConnectionsItem[] = (displayData.connections ?? []).filter(
+ (conn) => {
+ const { host, destinationIP, process } = conn.metadata;
+ return (
+ match(host || "") ||
+ match(destinationIP || "") ||
+ match(process || "")
+ );
+ },
+ );
+ if (orderFunc) conns = orderFunc(conns);
return [conns];
}, [displayData, match, curOrderOpt]);
@@ -145,6 +149,8 @@ const ConnectionsPage = () => {
});
}, [connections]);
+ const hasTableData = filterConn.length > 0;
+
return (
{
pt: 1,
mb: 0.5,
mx: "10px",
- height: "36px",
+ minHeight: "36px",
display: "flex",
alignItems: "center",
+ gap: 1,
userSelect: "text",
position: "sticky",
top: 0,
@@ -235,15 +242,29 @@ const ConnectionsPage = () => {
))}
)}
-
+ *": {
+ flex: 1,
+ },
+ }}
+ >
+
+
- {!filterConn || filterConn.length === 0 ? (
+ {!hasTableData ? (
) : isTableLayout ? (
detailRef.current?.open(detail)}
+ columnManagerOpen={isTableLayout && isColumnManagerOpen}
+ onOpenColumnManager={() => setIsColumnManagerOpen(true)}
+ onCloseColumnManager={() => setIsColumnManagerOpen(false)}
/>
) : (