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: bf06cbc87d (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: 7fc238c27b (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
This commit is contained in:
Tunglies
2026-06-07 13:08:15 +08:00
committed by GitHub
parent ee9d5126d3
commit 45afb1c15a
12 changed files with 1406 additions and 617 deletions
+1
View File
@@ -7,6 +7,7 @@
- 修复 gzip 压缩订阅响应被当作无效 YAML 导致导入失败的问题
- 修复订阅 URL 使用空密码 Basic Auth 时未发送认证信息的问题
- Linux 托盘可能与其他 tauri 程序托盘冲突导致图标异常
- 修复前端连接页面导致的内存泄漏
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
-1
View File
@@ -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",
-22
View File
@@ -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': {}
@@ -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<IConnectionsItem, unknown>[]
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 = ({
<SortableColumnItem
key={column.id}
column={column}
label={getColumnLabel(column)}
dragHandleLabel={t(
'connections.components.columnManager.dragHandle',
)}
disableToggle={
!column.getCanHide() ||
(column.getIsVisible() && visibleCount <= 1)
}
disableToggle={column.visible && visibleCount <= 1}
/>
))}
</List>
@@ -116,15 +118,13 @@ export const ConnectionColumnManager = ({
}
interface SortableColumnItemProps {
column: Column<IConnectionsItem, unknown>
label: string
column: ConnectionColumnOption
dragHandleLabel: string
disableToggle?: boolean
}
const SortableColumnItem = ({
column,
label,
dragHandleLabel,
disableToggle = false,
}: SortableColumnItemProps) => {
@@ -163,12 +163,12 @@ const SortableColumnItem = ({
>
<Checkbox
edge="start"
checked={column.getIsVisible()}
checked={column.visible}
disabled={disableToggle}
onChange={(event) => column.toggleVisibility(event.target.checked)}
/>
<ListItemText
primary={label}
primary={column.label}
slotProps={{ primary: { variant: 'body2' } }}
sx={{ mr: 1 }}
/>
@@ -185,11 +185,3 @@ const SortableColumnItem = ({
</ListItem>
)
}
const getColumnLabel = (column: Column<IConnectionsItem, unknown>) => {
const meta = column.columnDef.meta as { label?: string } | undefined
if (meta?.label) return meta.label
const header = column.columnDef.header
return typeof header === 'string' ? header : column.id
}
@@ -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<ConnectionDetailRef> }) {
const [open, setOpen] = useState(false)
const [detail, setDetail] = useState<IConnectionsItem>(null!)
const [detail, setDetail] = useState<IConnectionsItem | null>(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<ConnectionDetailRef> }) {
setDetail(detail)
setClosed(closed)
},
close: onClose,
}))
const onClose = () => setOpen(false)
return (
<Snackbar
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
@@ -0,0 +1,52 @@
import dayjs from 'dayjs'
import { memo, useSyncExternalStore } from 'react'
type RelativeTimeListener = () => void
let currentTime = Date.now()
let timerId: number | null = null
const listeners = new Set<RelativeTimeListener>()
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)}</>
})
@@ -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 (
<div style={itemStyle}>
<div style={contentStyle} onClick={handleShowDetail}>
<div style={primaryStyle}>{row.host}</div>
<div style={tagsStyle}>
<span style={tagStyle}>{row.network}</span>
<span style={tagStyle}>{row.type}</span>
{row.process && <span style={tagStyle}>{row.process}</span>}
{row.chains && <span style={tagStyle}>{row.chains}</span>}
<span style={tagStyle}>
<RelativeTime start={row.time} />
</span>
{showTraffic && (
<span style={tagStyle}>
{row.uploadSpeedText} / {row.downloadSpeedText}
</span>
)}
</div>
</div>
{!closed && (
<IconButton
size="small"
color="inherit"
onClick={onDelete}
title={t('connections.components.actions.closeConnection')}
aria-label={t('connections.components.actions.closeConnection')}
sx={actionStyle}
>
<CloseRounded fontSize="small" />
</IconButton>
)}
</div>
)
},
(prev, next) =>
prev.row === next.row &&
prev.closed === next.closed &&
prev.onShowDetail === next.onShowDetail,
)
@@ -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<string, ConnectionRowView>())
const previousConnectionsRef = useRef(new Map<string, IConnectionsItem>())
return useMemo(() => {
const previousRows = previousRowsRef.current
const previousConnections = previousConnectionsRef.current
const nextRows = new Map<string, ConnectionRowView>()
const nextConnections = new Map<string, IConnectionsItem>()
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])
}
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,7 @@ import { ReactNode, memo, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useConnectionData } from '@/hooks/use-connection-data'
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from '@/hooks/use-verge'
@@ -157,8 +157,8 @@ export const EnhancedTrafficStats = () => {
} = 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(() => {
+344 -77
View File
@@ -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<typeof setTimeout> | null = null
let flushTimer: ReturnType<typeof setTimeout> | null = null
let pendingMessageData: string | null = null
let lastFlushAt = 0
const connectionListeners = new Set<ConnectionListener>()
const summaryListeners = new Set<ConnectionListener>()
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<string, IConnectionsItem>()
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<string, IConnectionsItem>()
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<ConnectionMonitorData>({
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<ConnectionMonitorData>([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,
}
}
+64 -35
View File
@@ -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<Record<OrderKey, OrderFunc>>(
{} as Record<OrderKey, OrderFunc>,
)
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<OrderKey>('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<ConnectionDetailRef>(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 = () => {
<Button
size="small"
variant={connectionsType === 'active' ? 'contained' : 'outlined'}
onClick={() => setConnectionsType('active')}
onClick={() => selectConnectionsType('active')}
>
{t('connections.components.actions.active')}{' '}
{connections?.activeConnections.length}
@@ -200,7 +235,7 @@ const ConnectionsPage = () => {
<Button
size="small"
variant={connectionsType === 'closed' ? 'contained' : 'outlined'}
onClick={() => setConnectionsType('closed')}
onClick={() => selectConnectionsType('closed')}
>
{t('connections.components.actions.closed')}{' '}
{connections?.closedConnections.length}
@@ -249,26 +284,20 @@ const ConnectionsPage = () => {
) : isTableLayout ? (
<ConnectionTable
connections={filterConn}
onShowDetail={(detail) =>
detailRef.current?.open(detail, connectionsType === 'closed')
}
columnManagerOpen={isTableLayout && isColumnManagerOpen}
onShowDetail={showDetailById}
columnManagerOpen={isColumnManagerOpen}
onCloseColumnManager={() => setIsColumnManagerOpen(false)}
/>
) : (
<VirtualList
count={filterConn.length}
key={connectionsType}
count={displayRows.length}
estimateSize={56}
renderItem={(i) => (
<ConnectionItem
value={filterConn[i]}
<ConnectionRowItem
row={displayRows[i]}
closed={connectionsType === 'closed'}
onShowDetail={() =>
detailRef.current?.open(
filterConn[i],
connectionsType === 'closed',
)
}
onShowDetail={showDetailById}
/>
)}
style={{