mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-03 13:32:16 +08:00
* 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
898 lines
26 KiB
TypeScript
898 lines
26 KiB
TypeScript
import { useTheme } from '@mui/material/styles'
|
|
import { useLocalStorage } from 'foxact/use-local-storage'
|
|
import {
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type TouchEvent as ReactTouchEvent,
|
|
type UIEvent as ReactUIEvent,
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
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
|
|
const RESIZE_HANDLE_WIDTH = 6
|
|
const OVERSCAN_ROWS = 6
|
|
const MAX_ROW_SNAPSHOT_CACHE_SIZE = 2_000
|
|
|
|
const reconcileColumnOrder = (
|
|
storedOrder: string[],
|
|
baseFields: string[],
|
|
): string[] => {
|
|
const filtered = storedOrder.filter((field) => baseFields.includes(field))
|
|
const missing = baseFields.filter((field) => !filtered.includes(field))
|
|
return [...filtered, ...missing]
|
|
}
|
|
|
|
type ColumnField =
|
|
| 'host'
|
|
| 'download'
|
|
| 'upload'
|
|
| 'dlSpeed'
|
|
| 'ulSpeed'
|
|
| 'chains'
|
|
| 'rule'
|
|
| 'process'
|
|
| 'time'
|
|
| 'source'
|
|
| 'remoteDestination'
|
|
| 'type'
|
|
|
|
type ColumnSizingState = Record<string, number>
|
|
type VisibilityState = Record<string, boolean>
|
|
|
|
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 snapshot.host
|
|
case 'download':
|
|
return snapshot.row.download ?? 0
|
|
case 'upload':
|
|
return snapshot.row.upload ?? 0
|
|
case 'dlSpeed':
|
|
return snapshot.row.curDownload ?? 0
|
|
case 'ulSpeed':
|
|
return snapshot.row.curUpload ?? 0
|
|
case 'chains':
|
|
return snapshot.chainsText
|
|
case 'rule':
|
|
return snapshot.ruleText
|
|
case 'process':
|
|
return snapshot.process
|
|
case 'time':
|
|
return snapshot.startTime
|
|
case 'source':
|
|
return snapshot.source
|
|
case 'remoteDestination':
|
|
return snapshot.destination
|
|
case 'type':
|
|
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 <RelativeTime start={snapshot.row.start} />
|
|
return getConnectionCellValue(column.field, snapshot)
|
|
}
|
|
|
|
interface RowComponentProps {
|
|
row: IConnectionsItem
|
|
columns: DisplayColumn[]
|
|
onShowDetail: (id: string) => void
|
|
getSnapshot: (row: IConnectionsItem) => TableRowSnapshot
|
|
borderColor: string
|
|
virtualTop: number
|
|
}
|
|
|
|
const RowComponent = memo(
|
|
function RowComponent({
|
|
row,
|
|
columns,
|
|
onShowDetail,
|
|
getSnapshot,
|
|
borderColor,
|
|
virtualTop,
|
|
}: RowComponentProps) {
|
|
const handleClick = useCallback(
|
|
() => onShowDetail(row.id),
|
|
[onShowDetail, row.id],
|
|
)
|
|
const snapshot = getSnapshot(row)
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
position: 'absolute',
|
|
top: virtualTop,
|
|
left: 0,
|
|
right: 0,
|
|
height: ROW_HEIGHT,
|
|
cursor: 'pointer',
|
|
borderBottom: `1px solid ${borderColor}`,
|
|
}}
|
|
onClick={handleClick}
|
|
>
|
|
{columns.map((column) => (
|
|
<div
|
|
key={column.field}
|
|
style={{
|
|
boxSizing: 'border-box',
|
|
flex: `0 0 ${column.size}px`,
|
|
minWidth: column.minWidth,
|
|
maxWidth: column.maxWidth,
|
|
padding: '8px',
|
|
fontSize: 13,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent:
|
|
column.align === 'right' ? 'flex-end' : 'flex-start',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{renderCell(column, row, snapshot)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
},
|
|
(prev, next) =>
|
|
prev.row === next.row &&
|
|
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: (id: string) => void
|
|
columnManagerOpen: boolean
|
|
onCloseColumnManager: () => void
|
|
}
|
|
|
|
export const ConnectionTable = (props: Props) => {
|
|
const {
|
|
connections,
|
|
onShowDetail: rawOnShowDetail,
|
|
columnManagerOpen,
|
|
onCloseColumnManager,
|
|
} = props
|
|
const onShowDetailRef = useRef(rawOnShowDetail)
|
|
onShowDetailRef.current = rawOnShowDetail
|
|
const onShowDetail = useCallback(
|
|
(id: string) => onShowDetailRef.current(id),
|
|
[],
|
|
)
|
|
const { t } = useTranslation()
|
|
const theme = useTheme()
|
|
const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>(
|
|
'connection-table-widths',
|
|
{},
|
|
)
|
|
|
|
const [columnVisibilityModel, setColumnVisibilityModel] =
|
|
useLocalStorage<VisibilityState>(
|
|
'connection-table-visibility',
|
|
{},
|
|
{
|
|
serializer: JSON.stringify,
|
|
deserializer: (value) => {
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
if (parsed && typeof parsed === 'object') return parsed
|
|
} catch (err) {
|
|
console.warn('Failed to parse connection-table-visibility', err)
|
|
}
|
|
return {}
|
|
},
|
|
},
|
|
)
|
|
|
|
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
|
|
'connection-table-order',
|
|
[],
|
|
{
|
|
serializer: JSON.stringify,
|
|
deserializer: (value) => {
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
if (Array.isArray(parsed)) return parsed
|
|
} catch (err) {
|
|
console.warn('Failed to parse connection-table-order', err)
|
|
}
|
|
return []
|
|
},
|
|
},
|
|
)
|
|
|
|
const baseColumns = useMemo<BaseColumn[]>(() => {
|
|
return [
|
|
{
|
|
field: 'host',
|
|
headerName: t('connections.components.fields.host'),
|
|
width: 180,
|
|
minWidth: 140,
|
|
},
|
|
{
|
|
field: 'download',
|
|
headerName: t('shared.labels.downloaded'),
|
|
width: 76,
|
|
minWidth: 60,
|
|
align: 'right',
|
|
cell: (_, snapshot) => snapshot.downloadText,
|
|
},
|
|
{
|
|
field: 'upload',
|
|
headerName: t('shared.labels.uploaded'),
|
|
width: 76,
|
|
minWidth: 60,
|
|
align: 'right',
|
|
cell: (_, snapshot) => snapshot.uploadText,
|
|
},
|
|
{
|
|
field: 'dlSpeed',
|
|
headerName: t('connections.components.fields.dlSpeed'),
|
|
width: 76,
|
|
minWidth: 60,
|
|
align: 'right',
|
|
cell: (_, snapshot) => snapshot.downloadSpeedText,
|
|
},
|
|
{
|
|
field: 'ulSpeed',
|
|
headerName: t('connections.components.fields.ulSpeed'),
|
|
width: 76,
|
|
minWidth: 60,
|
|
align: 'right',
|
|
cell: (_, snapshot) => snapshot.uploadSpeedText,
|
|
},
|
|
{
|
|
field: 'chains',
|
|
headerName: t('connections.components.fields.chains'),
|
|
width: 280,
|
|
minWidth: 160,
|
|
},
|
|
{
|
|
field: 'rule',
|
|
headerName: t('connections.components.fields.rule'),
|
|
width: 220,
|
|
minWidth: 160,
|
|
},
|
|
{
|
|
field: 'process',
|
|
headerName: t('connections.components.fields.process'),
|
|
width: 180,
|
|
minWidth: 140,
|
|
},
|
|
{
|
|
field: 'time',
|
|
headerName: t('connections.components.fields.time'),
|
|
width: 100,
|
|
minWidth: 80,
|
|
align: 'right',
|
|
},
|
|
{
|
|
field: 'source',
|
|
headerName: t('connections.components.fields.source'),
|
|
width: 160,
|
|
minWidth: 120,
|
|
},
|
|
{
|
|
field: 'remoteDestination',
|
|
headerName: t('connections.components.fields.destination'),
|
|
width: 160,
|
|
minWidth: 120,
|
|
},
|
|
{
|
|
field: 'type',
|
|
headerName: t('connections.components.fields.type'),
|
|
width: 120,
|
|
minWidth: 80,
|
|
},
|
|
]
|
|
}, [t])
|
|
|
|
useEffect(() => {
|
|
setColumnOrder((prevValue) => {
|
|
const baseFields = baseColumns.map((col) => col.field)
|
|
const prev = Array.isArray(prevValue) ? prevValue : []
|
|
const reconciled = reconcileColumnOrder(prev, baseFields)
|
|
if (
|
|
reconciled.length === prev.length &&
|
|
reconciled.every((field, i) => field === prev[i])
|
|
) {
|
|
return prevValue
|
|
}
|
|
return reconciled
|
|
})
|
|
}, [baseColumns, setColumnOrder])
|
|
|
|
const orderedColumns = useMemo(() => {
|
|
const baseFields = baseColumns.map((column) => column.field)
|
|
const reconciledOrder = reconcileColumnOrder(columnOrder, baseFields)
|
|
const byField: Partial<Record<ColumnField, BaseColumn>> = {}
|
|
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<DisplayColumn[]>(() => {
|
|
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<SortingState | null>(null)
|
|
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
|
const rowSnapshotCacheRef = useRef(new Map<string, TableRowSnapshot>())
|
|
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<string>()
|
|
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<HTMLDivElement>) => {
|
|
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 visibleCount = baseColumns.reduce((count, column) => {
|
|
if (column.field === field) return count + (visible ? 1 : 0)
|
|
return count + ((current[column.field] ?? true) !== false ? 1 : 0)
|
|
}, 0)
|
|
if (visibleCount === 0) return current
|
|
|
|
const next: VisibilityState = {}
|
|
baseColumns.forEach((column) => {
|
|
if (column.field === field) {
|
|
if (!visible) next[column.field] = false
|
|
} else if (current[column.field] === false) {
|
|
next[column.field] = false
|
|
}
|
|
})
|
|
return next
|
|
})
|
|
},
|
|
[baseColumns, setColumnVisibilityModel],
|
|
)
|
|
|
|
const handleManagerOrderChange = useCallback(
|
|
(order: string[]) => {
|
|
const baseFields = baseColumns.map((col) => col.field)
|
|
setColumnOrder(reconcileColumnOrder(order, baseFields))
|
|
},
|
|
[baseColumns, setColumnOrder],
|
|
)
|
|
|
|
const handleResetColumns = useCallback(() => {
|
|
setColumnVisibilityModel({})
|
|
setColumnOrder(baseColumns.map((column) => column.field))
|
|
setColumnWidths({})
|
|
setSorting(null)
|
|
}, [baseColumns, setColumnOrder, setColumnVisibilityModel, setColumnWidths])
|
|
|
|
const managerColumns = useMemo<ConnectionColumnOption[]>(() => {
|
|
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,
|
|
}))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
window.addEventListener('mousemove', handleMouseMove)
|
|
window.addEventListener('mouseup', cleanup)
|
|
window.addEventListener('touchmove', handleTouchMove, { passive: true })
|
|
window.addEventListener('touchend', cleanup)
|
|
window.addEventListener('touchcancel', cleanup)
|
|
},
|
|
[setColumnWidths],
|
|
)
|
|
|
|
const handleResizeMouseDown = useCallback(
|
|
(column: DisplayColumn, event: ReactMouseEvent<HTMLDivElement>) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
startResize(
|
|
column.field,
|
|
event.clientX,
|
|
column.size,
|
|
column.minWidth,
|
|
column.maxWidth,
|
|
)
|
|
},
|
|
[startResize],
|
|
)
|
|
|
|
const handleResizeTouchStart = useCallback(
|
|
(column: DisplayColumn, event: ReactTouchEvent<HTMLDivElement>) => {
|
|
event.stopPropagation()
|
|
const touch = event.touches[0]
|
|
if (!touch) return
|
|
startResize(
|
|
column.field,
|
|
touch.clientX,
|
|
column.size,
|
|
column.minWidth,
|
|
column.maxWidth,
|
|
)
|
|
},
|
|
[startResize],
|
|
)
|
|
|
|
const borderColor = theme.palette.divider
|
|
const headerBackground = theme.palette.background.paper
|
|
const textSecondary = theme.palette.text.secondary
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
flex: 1,
|
|
minHeight: 0,
|
|
position: 'relative',
|
|
fontFamily: theme.typography.fontFamily,
|
|
}}
|
|
>
|
|
<div
|
|
ref={setScrollContainer}
|
|
onScroll={handleScroll}
|
|
style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflow: 'auto',
|
|
WebkitOverflowScrolling: 'touch',
|
|
overscrollBehavior: 'contain',
|
|
borderRadius: 8,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
minWidth: '100%',
|
|
width: tableWidth,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 2,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
borderBottom: `1px solid ${borderColor}`,
|
|
backgroundColor: headerBackground,
|
|
}}
|
|
>
|
|
{visibleColumns.map((column) => (
|
|
<div
|
|
key={column.field}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
position: 'relative',
|
|
boxSizing: 'border-box',
|
|
flex: `0 0 ${column.size}px`,
|
|
minWidth: column.minWidth,
|
|
maxWidth: column.maxWidth,
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: textSecondary,
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleSorting(column.field)}
|
|
style={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent:
|
|
column.align === 'right' ? 'flex-end' : 'flex-start',
|
|
gap: 4,
|
|
padding: 8,
|
|
border: 0,
|
|
background: 'transparent',
|
|
color: 'inherit',
|
|
font: 'inherit',
|
|
textAlign: column.align === 'right' ? 'right' : 'left',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{column.headerName}
|
|
{sorting?.id === column.field
|
|
? sorting.desc
|
|
? '▼'
|
|
: '▲'
|
|
: null}
|
|
</button>
|
|
<div
|
|
onMouseDown={(event) =>
|
|
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%)',
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
height: totalRowsHeight,
|
|
}}
|
|
>
|
|
{Array.from(
|
|
{ length: lastVisibleRow - firstVisibleRow },
|
|
(_, offset) => {
|
|
const index = firstVisibleRow + offset
|
|
const row = sortedConnections[index]
|
|
if (!row) return null
|
|
|
|
return (
|
|
<RowComponent
|
|
key={row.id}
|
|
row={row}
|
|
columns={visibleColumns}
|
|
onShowDetail={onShowDetail}
|
|
getSnapshot={getRowSnapshot}
|
|
borderColor={borderColor}
|
|
virtualTop={index * ROW_HEIGHT}
|
|
/>
|
|
)
|
|
},
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ConnectionColumnManager
|
|
open={columnManagerOpen}
|
|
columns={managerColumns}
|
|
onClose={onCloseColumnManager}
|
|
onOrderChange={handleManagerOrderChange}
|
|
onReset={handleResetColumns}
|
|
/>
|
|
</>
|
|
)
|
|
}
|