From 45afb1c15af5482ee296871f5e574f0dde83f6c9 Mon Sep 17 00:00:00 2001 From: Tunglies Date: Sun, 7 Jun 2026 13:08:15 +0800 Subject: [PATCH] fix(frontend): reduce long-running memory pressure in connections (#7227) * fix(connections): stabilize table memory usage Render the connections table and list from stable row view models, and replace the TanStack row/cell instantiation path with a lightweight virtualized table body. The leak was isolated to the frontend table render path: websocket data without the table stayed flat, while the normal table path kept producing large per-snapshot object graphs under active/closed churn. Introduced-by: bf06cbc87d9d4bc7563d2b127de1906e2a6201b6 (introduced the TanStack table path) Related-hot-path: 068678135 (made default ordering/filtering hot enough for long-running /connections sessions to amplify retained heap/RSS) Related-retention-path: 7fc238c27b52a46ad6a03a88eb130488f2e3e93c (moved Home stats to the full connection websocket subscription, so Home could keep connection snapshots alive for totals/count) Keep UI behavior intact: column visibility, column order, resizing, sorting, connection details, the closed-connection cap, and the 500ms refresh cadence remain. * fix(connections): lazily release connection snapshots Keep the full active/closed connection snapshot scoped to /connections by using a short React Query gcTime for the full subscription. Home now subscribes to a lightweight connection summary for totals and active count, so leaving /connections no longer keeps the full active/closed arrays and closed history alive for the dashboard. Only the selected Active or Closed mode feeds filtering, sorting, row-view derivation, and table/list rendering. Switching modes remounts the row subtree and closes detail/column-manager state without changing the capped closed-history behavior. * fix(connections): decouple collection from rendering Move the high-frequency connection websocket path out of React Query and into a module-level external store consumed with useSyncExternalStore. The store keeps one normalized active snapshot plus the capped closed history, continues collecting across route changes, and only notifies mounted subscribers. Home reads the same store through a lightweight summary snapshot, so leaving /connections no longer renders the table/list while connection history is still preserved. This replaces the short-gc release behavior that made Active/Closed history disappear after navigating away, and bounds retained data to current active connections plus the closed cap instead of React Query cache objects. * fix(connections): keep collection store authoritative Remove the temporary per-subscription gcTime override added for /connections. The connection monitor now owns active/closed retention outside React Query, so route changes should stop rendering without clearing collected history. This leaves the shared Mihomo websocket hook on its original 30s cache behavior for logs, traffic, and memory while /connections uses the external store introduced in the previous commit. * perf(connections): coalesce snapshots before parsing Keep only the latest raw websocket message during the 500ms throttle window and parse it when the store flushes. High-churn connection bursts no longer allocate parsed object graphs for snapshots that will be overwritten before React can observe them. Also compare incoming metadata through the same normalization used for retained records, so optional undefined fields do not prevent metadata reference reuse. * perf(connections): trim render allocations Reuse existing row view objects before formatting derived strings when the retained connection object reference is unchanged. Stable closed-history rows no longer allocate throwaway display strings on every snapshot. Measure the table scroll viewport on mount and resize instead of assuming a 1200px body, so virtual rendering starts from the real visible row count. Keep table state across Active/Closed switches and share the relative-time cell between table and list rows. * perf(connections): tighten shared time updates Refresh the shared relative-time clock before the first subscriber render and switch the listener registry back to a Set, avoiding stale labels after returning to /connections and keeping subscriber churn O(1). Also sort table time rows by the existing numeric startTime field, drop the redundant row-view startTime equality check, and remove one unnecessary table prop guard. * perf(connections): skip table-wide row projection Stop materializing ConnectionRowView objects for the whole filtered dataset in table layout. The page now keeps raw connection records for the table path and only formats visible cells inside the virtualized table body, while list layout keeps the cached row-view path. This removes the steady 500ms allocation pass that was still walking every filtered connection before table rendering. Detail lookup now reads the current filtered raw record directly, and the table clamps scroll state when switching between datasets of different lengths. * perf(connections): skip unnecessary filter clone when table not searching In table mode, avoid creating a filtered copy when search is empty: only apply search/sort work for non-empty query while retaining the same default order behavior. Also replace parsing in order comparators with numeric startTime to avoid per-tick Date object churn and use SearchBox state to detect an empty search. In table layout without search input, use upstream connection snapshot directly for rendering instead of cloning/sorting every interval tick. This removes a persistent per-update array allocation path while keeping list-mode sort/search behavior unchanged. * perf(connections): bound closed merge without full transient arrays Cap closed-history promotion while iterating previous active set without building a temporary large closed array. This preserves the 500-item limit while avoiding 100k-scale transient allocations when many connections close between snapshots. * fix(changelog): address memory leak caused by frontend * perf(connections): cache visible table cell snapshots Add a bounded row snapshot cache for the virtualized table so repeated paints and optional column sorting reuse stable formatted strings instead of rebuilding host/source/rule/chains/traffic text on every refresh. The cache is capped to avoid retaining large active sets. Also skip full connection-data snapshot merging when only summary listeners are mounted, keeping Home on the summary-only path without changing Connections history while the page is subscribed. * chore: update Changelog * chore: update Changelog --- Changelog.md | 1 + package.json | 1 - pnpm-lock.yaml | 22 - .../connection/connection-column-manager.tsx | 34 +- .../connection/connection-detail.tsx | 14 +- .../connection/connection-relative-time.tsx | 52 + .../connection/connection-row-item.tsx | 120 ++ .../connection/connection-row-view.ts | 179 +++ .../connection/connection-table.tsx | 1066 ++++++++++------- .../home/enhanced-traffic-stats.tsx | 14 +- src/hooks/use-connection-data.ts | 421 +++++-- src/pages/connections.tsx | 99 +- 12 files changed, 1406 insertions(+), 617 deletions(-) create mode 100644 src/components/connection/connection-relative-time.tsx create mode 100644 src/components/connection/connection-row-item.tsx create mode 100644 src/components/connection/connection-row-view.ts diff --git a/Changelog.md b/Changelog.md index e36bd7564..adf6f2430 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,7 @@ - 修复 gzip 压缩订阅响应被当作无效 YAML 导致导入失败的问题 - 修复订阅 URL 使用空密码 Basic Auth 时未发送认证信息的问题 - Linux 托盘可能与其他 tauri 程序托盘冲突导致图标异常 +- 修复前端连接页面导致的内存泄漏
✨ 新增功能 diff --git a/package.json b/package.json index 21e04afa7..f67915e31 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", "@tanstack/react-query": "^5.100.14", - "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.25", "@tauri-apps/api": "2.11.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ebbed2db..055f62a78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ importers: '@tanstack/react-query': specifier: ^5.100.14 version: 5.100.14(react@19.2.6) - '@tanstack/react-table': - specifier: ^8.21.3 - version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-virtual': specifier: ^3.13.25 version: 3.13.25(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1483,23 +1480,12 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-table@8.21.3': - resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} - engines: {node: '>=12'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - '@tanstack/react-virtual@3.13.25': resolution: {integrity: sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} - '@tanstack/virtual-core@3.15.0': resolution: {integrity: sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==} @@ -5002,20 +4988,12 @@ snapshots: '@tanstack/query-core': 5.100.14 react: 19.2.6 - '@tanstack/react-table@8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@tanstack/table-core': 8.21.3 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - '@tanstack/react-virtual@3.13.25(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/virtual-core': 3.15.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.15.0': {} '@tauri-apps/api@2.11.0': {} diff --git a/src/components/connection/connection-column-manager.tsx b/src/components/connection/connection-column-manager.tsx index ed0f4c744..866c06ee1 100644 --- a/src/components/connection/connection-column-manager.tsx +++ b/src/components/connection/connection-column-manager.tsx @@ -21,13 +21,19 @@ import { ListItem, ListItemText, } from '@mui/material' -import type { Column } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' +export interface ConnectionColumnOption { + id: string + label: string + visible: boolean + toggleVisibility: (visible: boolean) => void +} + interface Props { open: boolean - columns: Column[] + columns: ConnectionColumnOption[] onClose: () => void onOrderChange: (order: string[]) => void onReset: () => void @@ -49,7 +55,7 @@ export const ConnectionColumnManager = ({ const items = useMemo(() => columns.map((column) => column.id), [columns]) const visibleCount = useMemo( - () => columns.filter((column) => column.getIsVisible()).length, + () => columns.filter((column) => column.visible).length, [columns], ) @@ -89,14 +95,10 @@ export const ConnectionColumnManager = ({ ))} @@ -116,15 +118,13 @@ export const ConnectionColumnManager = ({ } interface SortableColumnItemProps { - column: Column - label: string + column: ConnectionColumnOption dragHandleLabel: string disableToggle?: boolean } const SortableColumnItem = ({ column, - label, dragHandleLabel, disableToggle = false, }: SortableColumnItemProps) => { @@ -163,12 +163,12 @@ const SortableColumnItem = ({ > column.toggleVisibility(event.target.checked)} /> @@ -185,11 +185,3 @@ 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-detail.tsx b/src/components/connection/connection-detail.tsx index 62a7bc9d5..b66698412 100644 --- a/src/components/connection/connection-detail.tsx +++ b/src/components/connection/connection-detail.tsx @@ -1,7 +1,7 @@ import { Box, Button, Snackbar, useTheme } from '@mui/material' import { useLockFn } from 'ahooks' import dayjs from 'dayjs' -import { useImperativeHandle, useState, type Ref } from 'react' +import { useCallback, useImperativeHandle, useState, type Ref } from 'react' import { useTranslation } from 'react-i18next' import { closeConnection } from 'tauri-plugin-mihomo-api' @@ -9,14 +9,21 @@ import parseTraffic from '@/utils/parse-traffic' export interface ConnectionDetailRef { open: (detail: IConnectionsItem, closed: boolean) => void + close: () => void } export function ConnectionDetail({ ref }: { ref?: Ref }) { const [open, setOpen] = useState(false) - const [detail, setDetail] = useState(null!) + const [detail, setDetail] = useState(null) const [closed, setClosed] = useState(false) const theme = useTheme() + const onClose = useCallback(() => { + setOpen(false) + setDetail(null) + setClosed(false) + }, []) + useImperativeHandle(ref, () => ({ open: (detail: IConnectionsItem, closed: boolean) => { if (open) return @@ -24,10 +31,9 @@ export function ConnectionDetail({ ref }: { ref?: Ref }) { setDetail(detail) setClosed(closed) }, + close: onClose, })) - const onClose = () => setOpen(false) - return ( void + +let currentTime = Date.now() +let timerId: number | null = null +const listeners = new Set() + +const startTimer = () => { + if (timerId !== null) return + + currentTime = Date.now() + timerId = window.setInterval(() => { + currentTime = Date.now() + listeners.forEach((listener) => listener()) + }, 5_000) +} + +const stopTimer = () => { + if (listeners.size > 0 || timerId === null) return + + window.clearInterval(timerId) + timerId = null +} + +const subscribeRelativeTime = (listener: RelativeTimeListener) => { + listeners.add(listener) + startTimer() + + return () => { + listeners.delete(listener) + stopTimer() + } +} + +const getRelativeTimeSnapshot = () => currentTime + +interface RelativeTimeProps { + start: string +} + +export const RelativeTime = memo(function RelativeTime({ + start, +}: RelativeTimeProps) { + const now = useSyncExternalStore( + subscribeRelativeTime, + getRelativeTimeSnapshot, + getRelativeTimeSnapshot, + ) + return <>{dayjs(start).from(now)} +}) diff --git a/src/components/connection/connection-row-item.tsx b/src/components/connection/connection-row-item.tsx new file mode 100644 index 000000000..9eb5de4e0 --- /dev/null +++ b/src/components/connection/connection-row-item.tsx @@ -0,0 +1,120 @@ +import { CloseRounded } from '@mui/icons-material' +import { IconButton } from '@mui/material' +import { useLockFn } from 'ahooks' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { closeConnection } from 'tauri-plugin-mihomo-api' + +import { RelativeTime } from './connection-relative-time' +import type { ConnectionRowView } from './connection-row-view' + +interface Props { + row: ConnectionRowView + closed: boolean + onShowDetail: (id: string) => void +} + +const tagStyle = { + boxSizing: 'border-box', + maxWidth: '100%', + padding: '0 4px', + border: '1px solid rgba(128,128,128,0.35)', + borderRadius: 4, + fontSize: 10, + lineHeight: 1.375, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} as const + +const itemStyle = { + boxSizing: 'border-box', + minHeight: 56, + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 48px 6px 12px', + borderBottom: '1px solid var(--divider-color)', + position: 'relative', + overflow: 'hidden', +} as const + +const contentStyle = { + minWidth: 0, + flex: 1, + cursor: 'pointer', + userSelect: 'text', +} as const + +const primaryStyle = { + fontSize: 14, + lineHeight: 1.4, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} as const + +const tagsStyle = { + display: 'flex', + flexWrap: 'wrap', + gap: 4, + marginTop: 4, + overflow: 'hidden', +} as const + +const actionStyle = { + position: 'absolute', + right: 8, + top: '50%', + transform: 'translateY(-50%)', +} as const + +export const ConnectionRowItem = memo( + function ConnectionRowItem({ row, closed, onShowDetail }: Props) { + const { t } = useTranslation() + const onDelete = useLockFn(async () => closeConnection(row.id)) + const handleShowDetail = useCallback( + () => onShowDetail(row.id), + [onShowDetail, row.id], + ) + const showTraffic = row.uploadSpeed >= 100 || row.downloadSpeed >= 100 + + return ( +
+
+
{row.host}
+
+ {row.network} + {row.type} + {row.process && {row.process}} + {row.chains && {row.chains}} + + + + {showTraffic && ( + + {row.uploadSpeedText} / {row.downloadSpeedText} + + )} +
+
+ {!closed && ( + + + + )} +
+ ) + }, + (prev, next) => + prev.row === next.row && + prev.closed === next.closed && + prev.onShowDetail === next.onShowDetail, +) diff --git a/src/components/connection/connection-row-view.ts b/src/components/connection/connection-row-view.ts new file mode 100644 index 000000000..1d7e7426d --- /dev/null +++ b/src/components/connection/connection-row-view.ts @@ -0,0 +1,179 @@ +import { useMemo, useRef } from 'react' + +const TRAFFIC_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + +export interface ConnectionRowView { + id: string + host: string + process: string + network: string + type: string + chains: string + rule: string + time: string + source: string + destination: string + uploadText: string + downloadText: string + uploadSpeedText: string + downloadSpeedText: string + upload: number + download: number + uploadSpeed: number + downloadSpeed: number + startTime: number + searchableHost: string + searchableDestinationIP: string + searchableProcess: string +} + +export const formatConnectionTraffic = (value?: number) => { + if (typeof value !== 'number') return 'NaN' + + const exp = + value < 1 + ? 0 + : Math.min(Math.floor(Math.log2(value) / 10), TRAFFIC_UNITS.length - 1) + const data = value / 1024 ** exp + const text = data >= 1000 ? data.toFixed(0) : data.toPrecision(3) + return `${text} ${TRAFFIC_UNITS[exp]}` +} + +export const formatConnectionChains = (chains: string[]) => { + let value = '' + for (let i = chains.length - 1; i >= 0; i -= 1) { + if (value) value += ' / ' + value += chains[i] + } + return value +} + +export const getConnectionDestination = (connection: IConnectionsItem) => { + const { metadata } = connection + return metadata.destinationIP + ? `${metadata.destinationIP}:${metadata.destinationPort}` + : `${metadata.remoteDestination}:${metadata.destinationPort}` +} + +export const getConnectionHost = (connection: IConnectionsItem) => { + const { metadata } = connection + return metadata.host + ? `${metadata.host}:${metadata.destinationPort}` + : `${metadata.remoteDestination}:${metadata.destinationPort}` +} + +export const getConnectionProcess = (connection: IConnectionsItem) => { + const { metadata } = connection + return metadata.process || metadata.processPath || '' +} + +export const getConnectionRule = (connection: IConnectionsItem) => { + const { rulePayload } = connection + return rulePayload ? `${connection.rule}(${rulePayload})` : connection.rule +} + +export const getConnectionSource = (connection: IConnectionsItem) => { + const { metadata } = connection + return `${metadata.sourceIP}:${metadata.sourcePort}` +} + +export const getConnectionTypeLabel = (connection: IConnectionsItem) => { + const { metadata } = connection + return `${metadata.type}(${metadata.network})` +} + +export const getConnectionStartTime = (connection: IConnectionsItem) => + Date.parse(connection.start || '') || 0 + +const createConnectionRowView = (connection: IConnectionsItem) => { + const uploadSpeed = connection.curUpload ?? 0 + const downloadSpeed = connection.curDownload ?? 0 + + return { + id: connection.id, + host: getConnectionHost(connection), + process: getConnectionProcess(connection), + network: connection.metadata.network, + type: connection.metadata.type, + chains: formatConnectionChains(connection.chains), + rule: getConnectionRule(connection), + time: connection.start, + source: getConnectionSource(connection), + destination: getConnectionDestination(connection), + uploadText: formatConnectionTraffic(connection.upload), + downloadText: formatConnectionTraffic(connection.download), + uploadSpeedText: `${formatConnectionTraffic(uploadSpeed)}/s`, + downloadSpeedText: `${formatConnectionTraffic(downloadSpeed)}/s`, + upload: connection.upload ?? 0, + download: connection.download ?? 0, + uploadSpeed, + downloadSpeed, + startTime: getConnectionStartTime(connection), + searchableHost: connection.metadata.host || '', + searchableDestinationIP: connection.metadata.destinationIP || '', + searchableProcess: connection.metadata.process || '', + } satisfies ConnectionRowView +} + +const sameConnectionRowView = ( + left: ConnectionRowView, + right: ConnectionRowView, +) => + left.host === right.host && + left.process === right.process && + left.network === right.network && + left.type === right.type && + left.chains === right.chains && + left.rule === right.rule && + left.time === right.time && + left.source === right.source && + left.destination === right.destination && + left.uploadText === right.uploadText && + left.downloadText === right.downloadText && + left.uploadSpeedText === right.uploadSpeedText && + left.downloadSpeedText === right.downloadSpeedText && + left.upload === right.upload && + left.download === right.download && + left.uploadSpeed === right.uploadSpeed && + left.downloadSpeed === right.downloadSpeed && + left.searchableHost === right.searchableHost && + left.searchableDestinationIP === right.searchableDestinationIP && + left.searchableProcess === right.searchableProcess + +export const useConnectionRowViews = (connections: IConnectionsItem[]) => { + const previousRowsRef = useRef(new Map()) + const previousConnectionsRef = useRef(new Map()) + + return useMemo(() => { + const previousRows = previousRowsRef.current + const previousConnections = previousConnectionsRef.current + const nextRows = new Map() + const nextConnections = new Map() + const rows: ConnectionRowView[] = [] + + connections.forEach((connection) => { + nextConnections.set(connection.id, connection) + + const previousRow = previousRows.get(connection.id) + const previousConnection = previousConnections.get(connection.id) + + let row: ConnectionRowView + if (previousRow && previousConnection === connection) { + row = previousRow + } else { + const nextRow = createConnectionRowView(connection) + row = + previousRow && sameConnectionRowView(previousRow, nextRow) + ? previousRow + : nextRow + } + + nextRows.set(connection.id, row) + rows.push(row) + }) + + previousRowsRef.current = nextRows + previousConnectionsRef.current = nextConnections + return rows + }, [connections]) +} diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index 8f57fec47..b1bcbdf1d 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -1,19 +1,4 @@ -import { Box } from '@mui/material' -import { - ColumnDef, - ColumnOrderState, - ColumnSizingState, - flexRender, - getCoreRowModel, - getSortedRowModel, - Row, - SortingState, - Updater, - useReactTable, - VisibilityState, -} from '@tanstack/react-table' -import { useVirtualizer } from '@tanstack/react-virtual' -import dayjs from 'dayjs' +import { useTheme } from '@mui/material/styles' import { useLocalStorage } from 'foxact/use-local-storage' import { memo, @@ -22,153 +7,33 @@ import { useMemo, useRef, useState, - useSyncExternalStore, - type ReactNode, + type MouseEvent as ReactMouseEvent, + type TouchEvent as ReactTouchEvent, + type UIEvent as ReactUIEvent, } 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' +import { + ConnectionColumnManager, + type ConnectionColumnOption, +} from './connection-column-manager' +import { RelativeTime } from './connection-relative-time' +import { + formatConnectionChains, + formatConnectionTraffic, + getConnectionDestination, + getConnectionHost, + getConnectionProcess, + getConnectionRule, + getConnectionSource, + getConnectionStartTime, + getConnectionTypeLabel, +} from './connection-row-view' const ROW_HEIGHT = 40 - -type TickListener = () => void -let _tickNow = Date.now() -const _tickListeners = new Set() -let _tickTimer: ReturnType | null = null - -const _startTick = () => { - if (_tickTimer !== null) return - _tickTimer = setInterval(() => { - _tickNow = Date.now() - _tickListeners.forEach((fn) => fn()) - }, 5000) -} - -const _stopTick = () => { - if (_tickListeners.size === 0 && _tickTimer !== null) { - clearInterval(_tickTimer) - _tickTimer = null - } -} - -const tickStore = { - subscribe: (listener: TickListener) => { - _tickListeners.add(listener) - _startTick() - return () => { - _tickListeners.delete(listener) - _stopTick() - } - }, - getSnapshot: () => _tickNow, -} - -interface RelativeTimeCellProps { - start: string -} - -const RelativeTimeCell = memo(function RelativeTimeCell({ - start, -}: RelativeTimeCellProps) { - const now = useSyncExternalStore(tickStore.subscribe, tickStore.getSnapshot) - return <>{dayjs(start).from(now)} -}) - -const SX_OUTER: React.ComponentProps['sx'] = { - display: 'flex', - flexDirection: 'column', - flex: 1, - minHeight: 0, - position: 'relative', - fontFamily: (theme) => theme.typography.fontFamily, -} - -const SX_SCROLL_CONTAINER: React.ComponentProps['sx'] = { - flex: 1, - minHeight: 0, - overflow: 'auto', - WebkitOverflowScrolling: 'touch', - overscrollBehavior: 'contain', - borderRadius: 1, - border: 'none', - '&::-webkit-scrollbar': { - height: 8, - }, -} - -const SX_HEADER_STICKY: React.ComponentProps['sx'] = { - position: 'sticky', - top: 0, - zIndex: 2, -} - -const SX_CELL_CONTENT: React.ComponentProps['sx'] = { - flex: 1, - display: 'flex', - alignItems: 'center', - gap: 0.5, - px: 1, - py: 1, -} - -const SX_RESIZE_HANDLE: React.ComponentProps['sx'] = { - cursor: 'col-resize', - position: 'absolute', - right: 0, - top: 0, - width: 4, - height: '100%', - transform: 'translateX(50%)', - '&:hover': { - backgroundColor: (theme) => theme.palette.action.active, - }, -} - -const SX_HEADER_ROW: React.ComponentProps['sx'] = { - display: 'flex', - borderBottom: (theme) => `1px solid ${theme.palette.divider}`, - backgroundColor: (theme) => theme.palette.background.paper, -} - -const SX_HEADER_CELL_BASE: React.ComponentProps['sx'] = { - display: 'flex', - alignItems: 'center', - position: 'relative', - boxSizing: 'border-box', - fontSize: 13, - fontWeight: 600, - color: 'text.secondary', - userSelect: 'none', - '&:hover': { - backgroundColor: (theme) => theme.palette.action.hover, - }, -} - -const SX_DATA_CELL_BASE: React.ComponentProps['sx'] = { - boxSizing: 'border-box', - px: 1, - fontSize: 13, - display: 'flex', - alignItems: 'center', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', -} - -const SX_ROW_BASE: React.ComponentProps['sx'] = { - display: 'flex', - position: 'absolute', - left: 0, - right: 0, - cursor: 'pointer', - borderBottom: (theme) => `1px solid ${theme.palette.divider}`, - '&:hover': { - backgroundColor: (theme) => theme.palette.action.hover, - }, -} +const RESIZE_HANDLE_WIDTH = 6 +const OVERSCAN_ROWS = 6 +const MAX_ROW_SNAPSHOT_CACHE_SIZE = 2_000 const reconcileColumnOrder = ( storedOrder: string[], @@ -193,108 +58,267 @@ type ColumnField = | 'remoteDestination' | 'type' -const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => { - const { metadata, rulePayload } = each +type ColumnSizingState = Record +type VisibilityState = Record +interface BaseColumn { + field: ColumnField + headerName: string + width: number + minWidth: number + maxWidth?: number + align?: 'left' | 'right' + cell?: (row: IConnectionsItem, snapshot: TableRowSnapshot) => string +} + +interface DisplayColumn extends BaseColumn { + size: number +} + +interface SortingState { + id: ColumnField + desc: boolean +} + +interface TableRowSnapshot { + row: IConnectionsItem + host: string + process: string + source: string + destination: string + chainsText: string + ruleText: string + typeLabel: string + startTime: number + uploadText: string + downloadText: string + uploadSpeedText: string + downloadSpeedText: string +} + +const resolveColumnSize = ( + column: BaseColumn, + storedSize: number | undefined, +) => { + if (typeof storedSize !== 'number' || !Number.isFinite(storedSize)) { + return column.width + } + + const boundedMin = Math.max(column.minWidth, storedSize) + return column.maxWidth === undefined + ? boundedMin + : Math.min(column.maxWidth, boundedMin) +} + +const sameStaticConnection = ( + left: IConnectionsItem, + right: IConnectionsItem, +) => + left.metadata === right.metadata && + left.chains === right.chains && + left.rule === right.rule && + left.rulePayload === right.rulePayload && + left.start === right.start + +const sameTrafficConnection = ( + left: IConnectionsItem, + right: IConnectionsItem, +) => + left.upload === right.upload && + left.download === right.download && + left.curUpload === right.curUpload && + left.curDownload === right.curDownload + +const createTableRowSnapshot = ( + row: IConnectionsItem, + previous?: TableRowSnapshot, +) => { + const previousRow = previous?.row + const sameStatic = previousRow && sameStaticConnection(previousRow, row) + const sameTraffic = previousRow && sameTrafficConnection(previousRow, row) + + if (sameStatic && sameTraffic && previous) return previous + + const upload = row.upload ?? 0 + const download = row.download ?? 0 + const curUpload = row.curUpload ?? 0 + const curDownload = row.curDownload ?? 0 + + return { + row, + host: sameStatic && previous ? previous.host : getConnectionHost(row), + process: + sameStatic && previous ? previous.process : getConnectionProcess(row), + source: sameStatic && previous ? previous.source : getConnectionSource(row), + destination: + sameStatic && previous + ? previous.destination + : getConnectionDestination(row), + chainsText: + sameStatic && previous + ? previous.chainsText + : formatConnectionChains(row.chains), + ruleText: + sameStatic && previous ? previous.ruleText : getConnectionRule(row), + typeLabel: + sameStatic && previous ? previous.typeLabel : getConnectionTypeLabel(row), + startTime: + sameStatic && previous ? previous.startTime : getConnectionStartTime(row), + uploadText: + sameTraffic && previous + ? previous.uploadText + : formatConnectionTraffic(upload), + downloadText: + sameTraffic && previous + ? previous.downloadText + : formatConnectionTraffic(download), + uploadSpeedText: + sameTraffic && previous + ? previous.uploadSpeedText + : `${formatConnectionTraffic(curUpload)}/s`, + downloadSpeedText: + sameTraffic && previous + ? previous.downloadSpeedText + : `${formatConnectionTraffic(curDownload)}/s`, + } +} + +const getConnectionCellValue = ( + field: ColumnField, + snapshot: TableRowSnapshot, +) => { switch (field) { case 'host': - return metadata.host - ? `${metadata.host}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}` + return snapshot.host case 'download': - return each.download + return snapshot.row.download ?? 0 case 'upload': - return each.upload + return snapshot.row.upload ?? 0 case 'dlSpeed': - return each.curDownload + return snapshot.row.curDownload ?? 0 case 'ulSpeed': - return each.curUpload + return snapshot.row.curUpload ?? 0 case 'chains': - return [...each.chains].reverse().join(' / ') + return snapshot.chainsText case 'rule': - return rulePayload ? `${each.rule}(${rulePayload})` : each.rule + return snapshot.ruleText case 'process': - return truncateStr(metadata.process || metadata.processPath) + return snapshot.process case 'time': - return each.start + return snapshot.startTime case 'source': - return `${metadata.sourceIP}:${metadata.sourcePort}` + return snapshot.source case 'remoteDestination': - return metadata.destinationIP - ? `${metadata.destinationIP}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}` + return snapshot.destination case 'type': - return `${metadata.type}(${metadata.network})` + return snapshot.typeLabel default: return '' } } +const compareConnectionCellValue = ( + field: ColumnField, + left: IConnectionsItem, + right: IConnectionsItem, + getSnapshot: (row: IConnectionsItem) => TableRowSnapshot, +) => { + const leftValue = getConnectionCellValue(field, getSnapshot(left)) + const rightValue = getConnectionCellValue(field, getSnapshot(right)) + + if (typeof leftValue === 'number' || typeof rightValue === 'number') { + return (Number(leftValue) || 0) - (Number(rightValue) || 0) + } + + return String(leftValue ?? '').localeCompare(String(rightValue ?? '')) +} + +const renderCell = ( + column: DisplayColumn, + row: IConnectionsItem, + snapshot: TableRowSnapshot, +) => { + if (column.cell) return column.cell(row, snapshot) + if (column.field === 'time') + return + return getConnectionCellValue(column.field, snapshot) +} + interface RowComponentProps { - row: Row - virtualStart: number - virtualSize: number - onShowDetail: (data: IConnectionsItem) => void + row: IConnectionsItem + columns: DisplayColumn[] + onShowDetail: (id: string) => void + getSnapshot: (row: IConnectionsItem) => TableRowSnapshot + borderColor: string + virtualTop: number } const RowComponent = memo( function RowComponent({ row, - virtualStart, - virtualSize, + columns, onShowDetail, + getSnapshot, + borderColor, + virtualTop, }: RowComponentProps) { const handleClick = useCallback( - () => onShowDetail(row.original), - [onShowDetail, row.original], + () => onShowDetail(row.id), + [onShowDetail, row.id], ) + const snapshot = getSnapshot(row) return ( - - {row.getVisibleCells().map((cell) => { - const meta = cell.column.columnDef.meta as { - align?: 'left' | 'right' - } - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ) - })} - + {columns.map((column) => ( +
+ {renderCell(column, row, snapshot)} +
+ ))} + ) }, (prev, next) => prev.row === next.row && - prev.virtualStart === next.virtualStart && - prev.virtualSize === next.virtualSize && - prev.onShowDetail === next.onShowDetail, + prev.columns === next.columns && + prev.virtualTop === next.virtualTop && + prev.onShowDetail === next.onShowDetail && + prev.getSnapshot === next.getSnapshot && + prev.borderColor === next.borderColor, ) interface Props { connections: IConnectionsItem[] - onShowDetail: (data: IConnectionsItem) => void + onShowDetail: (id: string) => void columnManagerOpen: boolean onCloseColumnManager: () => void } @@ -309,10 +333,11 @@ export const ConnectionTable = (props: Props) => { const onShowDetailRef = useRef(rawOnShowDetail) onShowDetailRef.current = rawOnShowDetail const onShowDetail = useCallback( - (data: IConnectionsItem) => onShowDetailRef.current(data), + (id: string) => onShowDetailRef.current(id), [], ) const { t } = useTranslation() + const theme = useTheme() const [columnWidths, setColumnWidths] = useLocalStorage( 'connection-table-widths', {}, @@ -353,15 +378,6 @@ export const ConnectionTable = (props: Props) => { }, ) - interface BaseColumn { - field: ColumnField - headerName: string - width?: number - minWidth?: number - align?: 'left' | 'right' - cell?: (row: IConnectionsItem) => ReactNode - } - const baseColumns = useMemo(() => { return [ { @@ -376,7 +392,7 @@ export const ConnectionTable = (props: Props) => { width: 76, minWidth: 60, align: 'right', - cell: (row) => parseTraffic(row.download).join(' '), + cell: (_, snapshot) => snapshot.downloadText, }, { field: 'upload', @@ -384,7 +400,7 @@ export const ConnectionTable = (props: Props) => { width: 76, minWidth: 60, align: 'right', - cell: (row) => parseTraffic(row.upload).join(' '), + cell: (_, snapshot) => snapshot.uploadText, }, { field: 'dlSpeed', @@ -392,7 +408,7 @@ export const ConnectionTable = (props: Props) => { width: 76, minWidth: 60, align: 'right', - cell: (row) => `${parseTraffic(row.curDownload).join(' ')}/s`, + cell: (_, snapshot) => snapshot.downloadSpeedText, }, { field: 'ulSpeed', @@ -400,7 +416,7 @@ export const ConnectionTable = (props: Props) => { width: 76, minWidth: 60, align: 'right', - cell: (row) => `${parseTraffic(row.curUpload).join(' ')}/s`, + cell: (_, snapshot) => snapshot.uploadSpeedText, }, { field: 'chains', @@ -463,262 +479,412 @@ export const ConnectionTable = (props: Props) => { }) }, [baseColumns, setColumnOrder]) - const handleColumnVisibilityChange = useCallback( - (update: Updater) => { + const orderedColumns = useMemo(() => { + const baseFields = baseColumns.map((column) => column.field) + const reconciledOrder = reconcileColumnOrder(columnOrder, baseFields) + const byField: Partial> = {} + baseColumns.forEach((column) => { + byField[column.field] = column + }) + + return reconciledOrder + .map((field) => byField[field as ColumnField]) + .filter((column): column is BaseColumn => Boolean(column)) + }, [baseColumns, columnOrder]) + + const visibleColumns = useMemo(() => { + return orderedColumns + .filter( + (column) => (columnVisibilityModel?.[column.field] ?? true) !== false, + ) + .map((column) => ({ + ...column, + size: resolveColumnSize(column, columnWidths?.[column.field]), + })) + }, [columnVisibilityModel, columnWidths, orderedColumns]) + + const [sorting, setSorting] = useState(null) + const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 }) + const scrollContainerRef = useRef(null) + const rowSnapshotCacheRef = useRef(new Map()) + const getRowSnapshot = useCallback((row: IConnectionsItem) => { + const cache = rowSnapshotCacheRef.current + const snapshot = createTableRowSnapshot(row, cache.get(row.id)) + cache.set(row.id, snapshot) + if (cache.size > MAX_ROW_SNAPSHOT_CACHE_SIZE) { + const oldestKey = cache.keys().next().value + if (oldestKey && oldestKey !== row.id) cache.delete(oldestKey) + } + return snapshot + }, []) + const updateViewport = useCallback((element: HTMLDivElement) => { + setViewport((current) => { + const next = { + scrollTop: element.scrollTop, + height: element.clientHeight, + } + return current.scrollTop === next.scrollTop && + current.height === next.height + ? current + : next + }) + }, []) + + const setScrollContainer = useCallback( + (element: HTMLDivElement | null) => { + scrollContainerRef.current = element + if (element) updateViewport(element) + }, + [updateViewport], + ) + + useEffect(() => { + const element = scrollContainerRef.current + if (!element) return + + if (typeof ResizeObserver === 'undefined') { + const handleResize = () => updateViewport(element) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + } + + const observer = new ResizeObserver(() => updateViewport(element)) + observer.observe(element) + return () => observer.disconnect() + }, [updateViewport]) + + useEffect(() => { + const element = scrollContainerRef.current + if (!element) return + + const maxScrollTop = Math.max( + 0, + element.scrollHeight - element.clientHeight, + ) + if (element.scrollTop <= maxScrollTop) return + + element.scrollTop = maxScrollTop + }, [connections.length]) + + useEffect(() => { + const cache = rowSnapshotCacheRef.current + if (cache.size <= connections.length + OVERSCAN_ROWS * 4) return + + const activeIds = new Set() + for (let i = 0; i < connections.length; i++) { + activeIds.add(connections[i].id) + } + cache.forEach((_, id) => { + if (!activeIds.has(id)) cache.delete(id) + }) + }, [connections]) + + const sortedConnections = useMemo(() => { + if (!sorting) return connections + + const direction = sorting.desc ? -1 : 1 + return [...connections].sort( + (left, right) => + compareConnectionCellValue(sorting.id, left, right, getRowSnapshot) * + direction, + ) + }, [connections, sorting, getRowSnapshot]) + + const tableWidth = useMemo( + () => visibleColumns.reduce((total, column) => total + column.size, 0), + [visibleColumns], + ) + const handleScroll = useCallback( + (event: ReactUIEvent) => { + updateViewport(event.currentTarget) + }, + [updateViewport], + ) + + const bodyScrollTop = Math.max(0, viewport.scrollTop - ROW_HEIGHT) + const firstVisibleRow = Math.min( + sortedConnections.length, + Math.max(0, Math.floor(bodyScrollTop / ROW_HEIGHT) - OVERSCAN_ROWS), + ) + const lastVisibleRow = Math.max( + firstVisibleRow, + Math.min( + sortedConnections.length, + Math.ceil((bodyScrollTop + viewport.height) / ROW_HEIGHT) + OVERSCAN_ROWS, + ), + ) + const totalRowsHeight = sortedConnections.length * ROW_HEIGHT + + const toggleSorting = useCallback((field: ColumnField) => { + setSorting((current) => { + if (!current || current.id !== field) return { id: field, desc: false } + if (!current.desc) return { id: field, desc: true } + return null + }) + }, []) + + const setColumnVisibility = useCallback( + (field: ColumnField, visible: boolean) => { setColumnVisibilityModel((prev) => { 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) + if (column.field === field) return count + (visible ? 1 : 0) + return count + ((current[column.field] ?? true) !== false ? 1 : 0) }, 0) + if (visibleCount === 0) return current - if (visibleCount === 0) { - return current - } - - const sanitized: VisibilityState = {} + const next: VisibilityState = {} baseColumns.forEach((column) => { - if (nextState[column.field] === false) { - sanitized[column.field] = false + if (column.field === field) { + if (!visible) next[column.field] = false + } else if (current[column.field] === false) { + next[column.field] = false } }) - return sanitized + return next }) }, [baseColumns, setColumnVisibilityModel], ) - 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(nextState, baseFields) - }) + const handleManagerOrderChange = useCallback( + (order: string[]) => { + const baseFields = baseColumns.map((col) => col.field) + setColumnOrder(reconcileColumnOrder(order, baseFields)) }, [baseColumns, setColumnOrder], ) - const [sorting, setSorting] = useState([]) + const handleResetColumns = useCallback(() => { + setColumnVisibilityModel({}) + setColumnOrder(baseColumns.map((column) => column.field)) + setColumnWidths({}) + setSorting(null) + }, [baseColumns, setColumnOrder, setColumnVisibilityModel, setColumnWidths]) - // columnDefs no longer depends on relativeNow — time column delegates to RelativeTimeCell - const columnDefs = useMemo[]>(() => { - return baseColumns.map((column) => { - let cell: ColumnDef['cell'] - if (column.field === 'time') { - cell = (ctx) => - } else if (column.cell) { - const renderCell = column.cell - cell = (ctx) => renderCell(ctx.row.original) - } else { - cell = (ctx) => - ctx.row.original - ? (getConnectionCellValue( - column.field, - ctx.row.original, - ) as ReactNode) - : null + const managerColumns = useMemo(() => { + return orderedColumns.map((column) => ({ + id: column.field, + label: column.headerName, + visible: (columnVisibilityModel?.[column.field] ?? true) !== false, + toggleVisibility: (visible) => setColumnVisibility(column.field, visible), + })) + }, [columnVisibilityModel, orderedColumns, setColumnVisibility]) + + const startResize = useCallback( + ( + field: ColumnField, + startClientX: number, + startWidth: number, + minWidth: number, + maxWidth: number | undefined, + ) => { + const handleMove = (clientX: number) => { + const nextWidth = Math.max( + minWidth, + startWidth + clientX - startClientX, + ) + setColumnWidths((prev) => ({ + ...(prev ?? {}), + [field]: maxWidth ? Math.min(maxWidth, nextWidth) : nextWidth, + })) } - return { - id: column.field, - accessorFn: (row) => getConnectionCellValue(column.field, row), - header: column.headerName, - size: column.width, - minSize: column.minWidth, - meta: { - align: column.align ?? 'left', - field: column.field, - label: column.headerName, - }, - cell, - } satisfies ColumnDef - }) - }, [baseColumns]) + const handleMouseMove = (event: MouseEvent) => handleMove(event.clientX) + const handleTouchMove = (event: TouchEvent) => { + const touch = event.touches[0] + if (touch) handleMove(touch.clientX) + } + const cleanup = () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', cleanup) + window.removeEventListener('touchmove', handleTouchMove) + window.removeEventListener('touchend', cleanup) + window.removeEventListener('touchcancel', cleanup) + } - const handleColumnSizingChange = useCallback( - (updater: Updater) => { - setColumnWidths((prev) => { - const prevState = prev ?? {} - const nextState = - typeof updater === 'function' ? updater(prevState) : updater - const sanitized: ColumnSizingState = {} - Object.entries(nextState).forEach(([key, size]) => { - if (typeof size === 'number' && Number.isFinite(size)) { - sanitized[key] = size - } - }) - return sanitized - }) + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', cleanup) + window.addEventListener('touchmove', handleTouchMove, { passive: true }) + window.addEventListener('touchend', cleanup) + window.addEventListener('touchcancel', cleanup) }, [setColumnWidths], ) - const table = useReactTable({ - data: connections, - state: { - columnVisibility: columnVisibilityModel ?? {}, - columnSizing: columnWidths, - columnOrder, - sorting, + const handleResizeMouseDown = useCallback( + (column: DisplayColumn, event: ReactMouseEvent) => { + event.preventDefault() + event.stopPropagation() + startResize( + column.field, + event.clientX, + column.size, + column.minWidth, + column.maxWidth, + ) }, - 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], + [startResize], ) - const handleResetColumns = useCallback(() => { - table.resetColumnVisibility() - table.resetColumnOrder() - }, [table]) + const handleResizeTouchStart = useCallback( + (column: DisplayColumn, event: ReactTouchEvent) => { + event.stopPropagation() + const touch = event.touches[0] + if (!touch) return + startResize( + column.field, + touch.clientX, + column.size, + column.minWidth, + column.maxWidth, + ) + }, + [startResize], + ) - const rows = table.getRowModel().rows - const tableContainerRef = useRef(null) - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => tableContainerRef.current, - estimateSize: () => ROW_HEIGHT, - overscan: 4, - }) - - const virtualRows = rowVirtualizer.getVirtualItems() - const totalSize = rowVirtualizer.getTotalSize() - const tableWidth = table.getTotalSize() - const managerColumns = table.getAllLeafColumns() + const borderColor = theme.palette.divider + const headerBackground = theme.palette.background.paper + const textSecondary = theme.palette.text.secondary return ( <> - - - +
+
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - if (header.isPlaceholder) { - return null - } - const meta = header.column.columnDef.meta as { - align?: 'left' | 'right' - field: string - } - return ( - - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - {{ - asc: '▲', - desc: '▼', - }[header.column.getIsSorted() as string] ?? null} - - {header.column.getCanResize() && ( - event.stopPropagation()} - onMouseDown={(event) => { - event.stopPropagation() - header.getResizeHandler()(event) - }} - onTouchStart={(event) => { - event.stopPropagation() - header.getResizeHandler()(event) - }} - sx={SX_RESIZE_HANDLE} - /> - )} - - ) - })} - - ))} - - - {virtualRows.map((virtualRow) => { - const row = rows[virtualRow.index] - if (!row) return null +
+ {visibleColumns.map((column) => ( +
+ +
+ handleResizeMouseDown(column, event) + } + onTouchStart={(event) => + handleResizeTouchStart(column, event) + } + style={{ + cursor: 'col-resize', + position: 'absolute', + right: 0, + top: 0, + width: RESIZE_HANDLE_WIDTH, + height: '100%', + transform: 'translateX(50%)', + }} + /> +
+ ))} +
+
+
+ {Array.from( + { length: lastVisibleRow - firstVisibleRow }, + (_, offset) => { + const index = firstVisibleRow + offset + const row = sortedConnections[index] + if (!row) return null - return ( - - ) - })} - - - - + return ( + + ) + }, + )} +
+
+
+ { } = useMemoryData() const { - response: { data: connections }, - } = useConnectionData() + response: { data: connectionSummary }, + } = useConnectionSummaryData() // Canvas组件现在直接从全局Hook获取数据,无需手动添加数据点 @@ -168,10 +168,10 @@ export const EnhancedTrafficStats = () => { const [down, downUnit] = parseTraffic(traffic?.down || 0) const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0) const [uploadTotal, uploadTotalUnit] = parseTraffic( - connections?.uploadTotal, + connectionSummary?.uploadTotal, ) const [downloadTotal, downloadTotalUnit] = parseTraffic( - connections?.downloadTotal, + connectionSummary?.downloadTotal, ) return { @@ -185,9 +185,9 @@ export const EnhancedTrafficStats = () => { uploadTotalUnit, downloadTotal, downloadTotalUnit, - connectionsCount: connections?.activeConnections.length, + connectionsCount: connectionSummary?.activeConnectionCount, } - }, [traffic, memory, connections]) + }, [traffic, memory, connectionSummary]) // 渲染流量图表 - 使用useMemo缓存渲染结果 const trafficGraphComponent = useMemo(() => { diff --git a/src/hooks/use-connection-data.ts b/src/hooks/use-connection-data.ts index aaf6756e4..b035f02aa 100644 --- a/src/hooks/use-connection-data.ts +++ b/src/hooks/use-connection-data.ts @@ -1,9 +1,14 @@ -import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useMemo, useSyncExternalStore } from 'react' import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' -import { useMihomoWsSubscription } from './use-mihomo-ws-subscription' - const MAX_CLOSED_CONNS_NUM = 500 +const CONNECTION_UPDATE_THROTTLE_MS = 500 +const CONNECTION_RECONNECT_DELAY_MS = 1_000 + +type ConnectionMetadata = IConnectionsItem['metadata'] +type ConnectionListener = () => void + +const metadataValue = (value?: string) => value || '' export const initConnData: ConnectionMonitorData = { uploadTotal: 0, @@ -19,6 +24,129 @@ export interface ConnectionMonitorData { closedConnections: IConnectionsItem[] } +export interface ConnectionSummaryData { + uploadTotal: number + downloadTotal: number + activeConnectionCount: number +} + +export const initConnSummaryData: ConnectionSummaryData = { + uploadTotal: 0, + downloadTotal: 0, + activeConnectionCount: 0, +} + +let connectionData: ConnectionMonitorData = initConnData +let connectionSummary: ConnectionSummaryData = initConnSummaryData +let connectionSocket: MihomoWebSocket | null = null +let connectionStarted = false +let connectionConnecting = false +let reconnectTimer: ReturnType | null = null +let flushTimer: ReturnType | null = null +let pendingMessageData: string | null = null +let lastFlushAt = 0 + +const connectionListeners = new Set() +const summaryListeners = new Set() + +const notifyConnectionListeners = () => { + connectionListeners.forEach((listener) => listener()) +} + +const notifySummaryListeners = () => { + summaryListeners.forEach((listener) => listener()) +} + +const sameMetadata = (left: ConnectionMetadata, right: ConnectionMetadata) => + metadataValue(left.network) === metadataValue(right.network) && + metadataValue(left.type) === metadataValue(right.type) && + metadataValue(left.host) === metadataValue(right.host) && + metadataValue(left.sourceIP) === metadataValue(right.sourceIP) && + metadataValue(left.sourcePort) === metadataValue(right.sourcePort) && + metadataValue(left.destinationPort) === + metadataValue(right.destinationPort) && + metadataValue(left.destinationIP) === metadataValue(right.destinationIP) && + metadataValue(left.remoteDestination) === + metadataValue(right.remoteDestination) && + metadataValue(left.process) === metadataValue(right.process) && + metadataValue(left.processPath) === metadataValue(right.processPath) + +const normalizeMetadata = ( + metadata: ConnectionMetadata, + previous?: ConnectionMetadata, +): ConnectionMetadata => { + if (previous && sameMetadata(previous, metadata)) return previous + + return { + network: metadata.network || '', + type: metadata.type || '', + host: metadata.host || '', + sourceIP: metadata.sourceIP || '', + sourcePort: metadata.sourcePort || '', + destinationPort: metadata.destinationPort || '', + destinationIP: metadata.destinationIP || '', + remoteDestination: metadata.remoteDestination || '', + process: metadata.process || '', + processPath: metadata.processPath || '', + } +} + +const sameChains = (left: string[], right: string[]) => { + if (left.length !== right.length) return false + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) return false + } + return true +} + +const normalizeChains = (chains: string[], previous?: string[]) => { + if (previous && sameChains(previous, chains)) return previous + return chains.slice() +} + +const normalizeConnection = ( + connection: IConnectionsItem, + previous?: IConnectionsItem, +): IConnectionsItem => { + const metadata = normalizeMetadata(connection.metadata, previous?.metadata) + const chains = normalizeChains(connection.chains || [], previous?.chains) + const upload = connection.upload ?? 0 + const download = connection.download ?? 0 + const curUpload = previous ? upload - previous.upload : 0 + const curDownload = previous ? download - previous.download : 0 + const rule = connection.rule || '' + const rulePayload = connection.rulePayload || '' + const start = connection.start || '' + + if ( + previous && + previous.metadata === metadata && + previous.chains === chains && + previous.upload === upload && + previous.download === download && + previous.curUpload === curUpload && + previous.curDownload === curDownload && + previous.rule === rule && + previous.rulePayload === rulePayload && + previous.start === start + ) { + return previous + } + + return { + id: connection.id, + metadata, + upload, + download, + start, + chains, + rule, + rulePayload, + curUpload, + curDownload, + } +} + const mergeConnectionSnapshot = ( payload: IConnections, previous: ConnectionMonitorData = initConnData, @@ -26,57 +154,51 @@ const mergeConnectionSnapshot = ( const nextConnections = payload.connections ?? [] const previousActive = previous.activeConnections ?? [] const previousClosed = previous.closedConnections ?? [] - - const nextById = new Map() - for (let i = 0; i < nextConnections.length; i++) { - nextById.set(nextConnections[i].id, nextConnections[i]) - } - - const carried: IConnectionsItem[] = [] - const dropped: IConnectionsItem[] = [] + const previousActiveById = new Map() for (let i = 0; i < previousActive.length; i++) { - const prev = previousActive[i] - const next = nextById.get(prev.id) - if (next !== undefined) { - nextById.delete(prev.id) - if (prev.upload === next.upload && prev.download === next.download) { - // Reuse prev reference: row identity stability is the contract Stage 2 memo relies on. - carried.push(prev) - } else { - carried.push({ - ...next, - curUpload: next.upload - prev.upload, - curDownload: next.download - prev.download, - }) - } - } else { - dropped.push(prev) - } + const previousConnection = previousActive[i] + previousActiveById.set(previousConnection.id, previousConnection) } - const activeConnections: IConnectionsItem[] = carried + const activeConnections: IConnectionsItem[] = [] for (let i = 0; i < nextConnections.length; i++) { - const conn = nextConnections[i] - if (nextById.has(conn.id)) { - activeConnections.push({ - ...conn, - curUpload: 0, - curDownload: 0, - }) + const connection = nextConnections[i] + const previousConnection = previousActiveById.get(connection.id) + if (previousConnection) previousActiveById.delete(connection.id) + activeConnections.push(normalizeConnection(connection, previousConnection)) + } + + if (previousActiveById.size === 0) { + return { + uploadTotal: payload.uploadTotal ?? 0, + downloadTotal: payload.downloadTotal ?? 0, + activeConnections, + closedConnections: previousClosed, } } - const rawClosedLen = previousClosed.length + dropped.length - let closedConnections: IConnectionsItem[] - if (rawClosedLen <= MAX_CLOSED_CONNS_NUM) { - closedConnections = previousClosed.concat(dropped) - } else { - const skipPrev = rawClosedLen - MAX_CLOSED_CONNS_NUM - closedConnections = - skipPrev >= previousClosed.length - ? dropped.slice(skipPrev - previousClosed.length) - : previousClosed.slice(skipPrev).concat(dropped) + const removedConnectionCount = previousActiveById.size + const dropFromClosed = Math.max( + 0, + previousClosed.length + removedConnectionCount - MAX_CLOSED_CONNS_NUM, + ) + const closedConnections = + dropFromClosed >= previousClosed.length + ? [] + : previousClosed.slice(dropFromClosed) + + const keepFromRemoved = MAX_CLOSED_CONNS_NUM - closedConnections.length + let skipRemoved = Math.max(0, removedConnectionCount - keepFromRemoved) + + for (let i = 0; i < previousActive.length; i++) { + const connection = previousActive[i] + if (!previousActiveById.has(connection.id)) continue + if (skipRemoved > 0) { + skipRemoved -= 1 + continue + } + closedConnections.push(connection) } return { @@ -87,43 +209,188 @@ const mergeConnectionSnapshot = ( } } -export const useConnectionData = () => { - const queryClient = useQueryClient() - const { response, refresh, subscriptionCacheKey } = - useMihomoWsSubscription({ - storageKey: 'mihomo_connection_date', - buildSubscriptKey: (date) => `getClashConnection-${date}`, - fallbackData: initConnData, - connect: () => MihomoWebSocket.connect_connections(), - throttleMs: 16, - setupHandlers: ({ next, scheduleReconnect }) => ({ - handleMessage: (data) => { - if (data.startsWith('Websocket error')) { - next(data) - void scheduleReconnect() - return - } +const mergeConnectionSummary = ( + payload: IConnections, +): ConnectionSummaryData => ({ + uploadTotal: payload.uploadTotal ?? 0, + downloadTotal: payload.downloadTotal ?? 0, + activeConnectionCount: payload.connections?.length ?? 0, +}) - next(null, (old = initConnData) => - mergeConnectionSnapshot(JSON.parse(data) as IConnections, old), - ) - }, - }), - }) +const flushPendingMessage = () => { + flushTimer = null + const messageData = pendingMessageData + pendingMessageData = null + if (!messageData) return - const clearClosedConnections = () => { - if (!subscriptionCacheKey) return - queryClient.setQueryData([subscriptionCacheKey], { - uploadTotal: response.data?.uploadTotal ?? 0, - downloadTotal: response.data?.downloadTotal ?? 0, - activeConnections: response.data?.activeConnections ?? [], - closedConnections: [], - }) + let payload: IConnections + try { + payload = JSON.parse(messageData) as IConnections + } catch (err) { + console.error('[Connections] Failed to parse websocket payload', err) + return } + lastFlushAt = Date.now() + connectionSummary = mergeConnectionSummary(payload) + notifySummaryListeners() + + if (connectionListeners.size === 0) return + + connectionData = mergeConnectionSnapshot(payload, connectionData) + notifyConnectionListeners() +} + +const enqueueConnectionMessage = (messageData: string) => { + pendingMessageData = messageData + if (flushTimer) return + + const elapsed = Date.now() - lastFlushAt + if (elapsed >= CONNECTION_UPDATE_THROTTLE_MS) { + flushPendingMessage() + return + } + + flushTimer = window.setTimeout( + flushPendingMessage, + CONNECTION_UPDATE_THROTTLE_MS - elapsed, + ) +} + +const clearReconnectTimer = () => { + if (!reconnectTimer) return + window.clearTimeout(reconnectTimer) + reconnectTimer = null +} + +const closeConnectionSocket = async () => { + const socket = connectionSocket + connectionSocket = null + if (!socket) return + + try { + await socket.close() + } catch (err) { + console.warn('Failed to close connection websocket', err) + } +} + +const scheduleReconnect = () => { + if (reconnectTimer) return + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null + void connectConnectionSocket() + }, CONNECTION_RECONNECT_DELAY_MS) +} + +async function reconnectConnectionSocket() { + await closeConnectionSocket() + scheduleReconnect() +} + +async function connectConnectionSocket() { + if (connectionSocket || connectionConnecting) return + + clearReconnectTimer() + connectionConnecting = true + + try { + const socket = await MihomoWebSocket.connect_connections() + connectionSocket = socket + socket.addListener((message) => { + if (message.type !== 'Text') return + if (message.data.startsWith('Websocket error')) { + void reconnectConnectionSocket() + return + } + + enqueueConnectionMessage(message.data) + }) + } catch { + scheduleReconnect() + } finally { + connectionConnecting = false + } +} + +const startConnectionMonitor = () => { + if (connectionStarted) return + connectionStarted = true + void connectConnectionSocket() +} + +const getConnectionSnapshot = () => connectionData +const getConnectionSummarySnapshot = () => connectionSummary + +const subscribeConnectionData = (listener: ConnectionListener) => { + startConnectionMonitor() + connectionListeners.add(listener) + return () => { + connectionListeners.delete(listener) + } +} + +const subscribeConnectionSummary = (listener: ConnectionListener) => { + startConnectionMonitor() + summaryListeners.add(listener) + return () => { + summaryListeners.delete(listener) + } +} + +const refreshConnectionData = () => { + pendingMessageData = null + if (flushTimer) { + window.clearTimeout(flushTimer) + flushTimer = null + } + + void reconnectConnectionSocket() +} + +const clearClosedConnectionData = () => { + if (connectionData.closedConnections.length === 0) return + connectionData = { + ...connectionData, + closedConnections: [], + } + notifyConnectionListeners() +} + +export const useConnectionData = () => { + const data = useSyncExternalStore( + subscribeConnectionData, + getConnectionSnapshot, + getConnectionSnapshot, + ) + const response = useMemo(() => ({ data }), [data]) + const refreshGetClashConnection = useCallback(() => { + refreshConnectionData() + }, []) + const clearClosedConnections = useCallback(() => { + clearClosedConnectionData() + }, []) + return { response, - refreshGetClashConnection: refresh, + refreshGetClashConnection, clearClosedConnections, } } + +export const useConnectionSummaryData = () => { + const data = useSyncExternalStore( + subscribeConnectionSummary, + getConnectionSummarySnapshot, + getConnectionSummarySnapshot, + ) + const response = useMemo(() => ({ data }), [data]) + const refreshGetClashConnectionSummary = useCallback(() => { + refreshConnectionData() + }, []) + + return { + response, + refreshGetClashConnectionSummary, + } +} diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx index b25790838..db23c9012 100644 --- a/src/pages/connections.tsx +++ b/src/pages/connections.tsx @@ -24,13 +24,18 @@ import { BasePage, BaseSearchBox, BaseStyledSelect, + type SearchState, VirtualList, } from '@/components/base' import { ConnectionDetail, ConnectionDetailRef, } from '@/components/connection/connection-detail' -import { ConnectionItem } from '@/components/connection/connection-item' +import { ConnectionRowItem } from '@/components/connection/connection-row-item' +import { + getConnectionStartTime, + useConnectionRowViews, +} from '@/components/connection/connection-row-view' import { ConnectionTable } from '@/components/connection/connection-table' import { useConnectionData } from '@/hooks/use-connection-data' import { useConnectionSetting } from '@/hooks/use-connection-setting' @@ -44,22 +49,20 @@ const ORDER_OPTIONS = [ labelKey: 'connections.components.order.default', fn: (list: IConnectionsItem[]) => list.sort( - (a, b) => - new Date(b.start || '0').getTime()! - - new Date(a.start || '0').getTime()!, + (a, b) => getConnectionStartTime(b) - getConnectionStartTime(a), ), }, { id: 'uploadSpeed', labelKey: 'connections.components.order.uploadSpeed', fn: (list: IConnectionsItem[]) => - list.sort((a, b) => b.curUpload! - a.curUpload!), + list.sort((a, b) => (b.curUpload ?? 0) - (a.curUpload ?? 0)), }, { id: 'downloadSpeed', labelKey: 'connections.components.order.downloadSpeed', fn: (list: IConnectionsItem[]) => - list.sort((a, b) => b.curDownload! - a.curDownload!), + list.sort((a, b) => (b.curDownload ?? 0) - (a.curDownload ?? 0)), }, ] as const @@ -73,11 +76,13 @@ const orderFunctionMap = ORDER_OPTIONS.reduce>( {} as Record, ) +const EMPTY_CONNECTIONS: IConnectionsItem[] = [] const ConnectionsPage = () => { const { t } = useTranslation() const [match, setMatch] = useState<(input: string) => boolean>( () => () => true, ) + const [hasSearch, setHasSearch] = useState(false) const [curOrderOpt, setCurOrderOpt] = useState('default') const [connectionsType, setConnectionsType] = useState<'active' | 'closed'>( 'active', @@ -94,32 +99,62 @@ const ConnectionsPage = () => { const [isColumnManagerOpen, setIsColumnManagerOpen] = useState(false) - const [filterConn] = useMemo(() => { + const selectedConnections = + connectionsType === 'active' + ? (connections?.activeConnections ?? EMPTY_CONNECTIONS) + : (connections?.closedConnections ?? EMPTY_CONNECTIONS) + + const filterConn = useMemo(() => { const orderFunc = orderFunctionMap[curOrderOpt] - const conns = - (connectionsType === 'active' - ? connections?.activeConnections - : connections?.closedConnections) ?? [] - let matchConns = conns.filter((conn) => { + + if (isTableLayout && !hasSearch) return selectedConnections + if (!hasSearch) return orderFunc([...selectedConnections]) + + const matchConns = selectedConnections.filter((conn) => { const { host, destinationIP, process } = conn.metadata return ( match(host || '') || match(destinationIP || '') || match(process || '') ) }) - if (orderFunc) matchConns = orderFunc(matchConns ?? []) + return orderFunc ? orderFunc(matchConns) : matchConns + }, [selectedConnections, isTableLayout, hasSearch, match, curOrderOpt]) - return [matchConns] - }, [connections, connectionsType, match, curOrderOpt]) - - const onCloseAll = useLockFn(closeAllConnections) + const displayRows = useConnectionRowViews( + isTableLayout ? EMPTY_CONNECTIONS : filterConn, + ) const detailRef = useRef(null!) - const handleSearch = useCallback((match: (content: string) => boolean) => { - setMatch(() => match) - }, []) + const selectConnectionsType = useCallback( + (type: 'active' | 'closed') => { + if (type === connectionsType) return + detailRef.current?.close() + setIsColumnManagerOpen(false) + setConnectionsType(type) + }, + [connectionsType], + ) + const showDetailById = useCallback( + (id: string) => { + const connection = filterConn.find((item) => item.id === id) + if (connection) { + detailRef.current?.open(connection, connectionsType === 'closed') + } + }, + [connectionsType, filterConn], + ) + + const onCloseAll = useLockFn(closeAllConnections) + + const handleSearch = useCallback( + (match: (content: string) => boolean, state: SearchState) => { + setMatch(() => match) + setHasSearch(state.text.length > 0) + }, + [], + ) const hasTableData = filterConn.length > 0 return ( @@ -192,7 +227,7 @@ const ConnectionsPage = () => {