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 = () => {