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 ( + + + {t("connections.components.columnManager.title")} + + + + + + {columns.map((column) => ( + + ))} + + + + + + + + + + ); +}; + +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)} /> ) : (