mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-26 20:50:30 +08:00
feat: add table view to connection page
This commit is contained in:
parent
a5a583fdc5
commit
51b8c879ea
465
src/renderer/src/components/connections/connection-table.tsx
Normal file
465
src/renderer/src/components/connections/connection-table.tsx
Normal file
@ -0,0 +1,465 @@
|
||||
import React, { useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Button, Chip } from '@heroui/react'
|
||||
import { calcTraffic } from '@renderer/utils/calc'
|
||||
import dayjs from '@renderer/utils/dayjs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CgClose, CgTrash } from 'react-icons/cg'
|
||||
|
||||
interface Props {
|
||||
connections: IMihomoConnectionDetail[]
|
||||
setSelected: React.Dispatch<React.SetStateAction<IMihomoConnectionDetail | undefined>>
|
||||
setIsDetailModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
close: (id: string) => void
|
||||
visibleColumns: Set<string>
|
||||
onColumnWidthChange?: (widths: Record<string, number>) => void
|
||||
onSortChange?: (column: string | null, direction: 'asc' | 'desc') => void
|
||||
initialColumnWidths?: Record<string, number>
|
||||
initialSortColumn?: string
|
||||
initialSortDirection?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
interface ColumnConfig {
|
||||
key: string
|
||||
label: string
|
||||
width: number
|
||||
minWidth: number
|
||||
visible: boolean
|
||||
getValue: (connection: IMihomoConnectionDetail) => string | number
|
||||
render?: (connection: IMihomoConnectionDetail) => React.ReactNode
|
||||
sortValue?: (connection: IMihomoConnectionDetail) => string | number
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMNS: Omit<ColumnConfig, 'label'>[] = [
|
||||
{
|
||||
key: 'status',
|
||||
width: 80,
|
||||
minWidth: 60,
|
||||
visible: true,
|
||||
getValue: (conn) => conn.isActive ? 'active' : 'closed',
|
||||
sortValue: (conn) => conn.isActive ? 1 : 0
|
||||
},
|
||||
{
|
||||
key: 'establishTime',
|
||||
width: 105,
|
||||
minWidth: 80,
|
||||
visible: true,
|
||||
getValue: (conn) => dayjs(conn.start).fromNow(),
|
||||
sortValue: (conn) => dayjs(conn.start).unix()
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
width: 120,
|
||||
minWidth: 100,
|
||||
visible: true,
|
||||
getValue: (conn) => `${conn.metadata.type}(${conn.metadata.network})`,
|
||||
render: (conn) => (
|
||||
<span className="text-xs">{conn.metadata.type}({conn.metadata.network.toUpperCase()})</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'host',
|
||||
width: 200,
|
||||
minWidth: 150,
|
||||
visible: true,
|
||||
getValue: (conn) => conn.metadata.host || '-'
|
||||
},
|
||||
{
|
||||
key: 'sniffHost',
|
||||
width: 200,
|
||||
minWidth: 150,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.sniffHost || '-'
|
||||
},
|
||||
{
|
||||
key: 'process',
|
||||
width: 150,
|
||||
minWidth: 120,
|
||||
visible: true,
|
||||
getValue: (conn) =>
|
||||
conn.metadata.process
|
||||
? `${conn.metadata.process}${conn.metadata.uid ? `(${conn.metadata.uid})` : ''}`
|
||||
: '-'
|
||||
},
|
||||
{
|
||||
key: 'processPath',
|
||||
width: 250,
|
||||
minWidth: 200,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.processPath || '-'
|
||||
},
|
||||
{
|
||||
key: 'rule',
|
||||
width: 150,
|
||||
minWidth: 120,
|
||||
visible: true,
|
||||
getValue: (conn) => `${conn.rule}${conn.rulePayload ? `(${conn.rulePayload})` : ''}`
|
||||
},
|
||||
{
|
||||
key: 'proxyChain',
|
||||
width: 150,
|
||||
minWidth: 120,
|
||||
visible: true,
|
||||
getValue: (conn) => [...conn.chains].reverse().join('>>')
|
||||
},
|
||||
{
|
||||
key: 'sourceIP',
|
||||
width: 140,
|
||||
minWidth: 120,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.sourceIP || '-'
|
||||
},
|
||||
{
|
||||
key: 'sourcePort',
|
||||
width: 100,
|
||||
minWidth: 80,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.sourcePort || '-'
|
||||
},
|
||||
{
|
||||
key: 'destinationPort',
|
||||
width: 100,
|
||||
minWidth: 80,
|
||||
visible: true,
|
||||
getValue: (conn) => conn.metadata.destinationPort || '-'
|
||||
},
|
||||
{
|
||||
key: 'inboundIP',
|
||||
width: 140,
|
||||
minWidth: 120,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.inboundIP || '-'
|
||||
},
|
||||
{
|
||||
key: 'inboundPort',
|
||||
width: 100,
|
||||
minWidth: 80,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.inboundPort || '-'
|
||||
},
|
||||
{
|
||||
key: 'uploadSpeed',
|
||||
width: 110,
|
||||
minWidth: 90,
|
||||
visible: true,
|
||||
getValue: (conn) => `${calcTraffic(conn.uploadSpeed || 0)}/s`,
|
||||
sortValue: (conn) => conn.uploadSpeed || 0
|
||||
},
|
||||
{
|
||||
key: 'downloadSpeed',
|
||||
width: 110,
|
||||
minWidth: 90,
|
||||
visible: true,
|
||||
getValue: (conn) => `${calcTraffic(conn.downloadSpeed || 0)}/s`,
|
||||
sortValue: (conn) => conn.downloadSpeed || 0
|
||||
},
|
||||
{
|
||||
key: 'upload',
|
||||
width: 100,
|
||||
minWidth: 80,
|
||||
visible: true,
|
||||
getValue: (conn) => calcTraffic(conn.upload),
|
||||
sortValue: (conn) => conn.upload
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
width: 100,
|
||||
minWidth: 80,
|
||||
visible: true,
|
||||
getValue: (conn) => calcTraffic(conn.download),
|
||||
sortValue: (conn) => conn.download
|
||||
},
|
||||
{
|
||||
key: 'dscp',
|
||||
width: 80,
|
||||
minWidth: 60,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.dscp.toString()
|
||||
},
|
||||
{
|
||||
key: 'remoteDestination',
|
||||
width: 200,
|
||||
minWidth: 150,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.remoteDestination || '-'
|
||||
},
|
||||
{
|
||||
key: 'dnsMode',
|
||||
width: 120,
|
||||
minWidth: 100,
|
||||
visible: false,
|
||||
getValue: (conn) => conn.metadata.dnsMode || '-'
|
||||
}
|
||||
]
|
||||
|
||||
const ConnectionTable: React.FC<Props> = ({
|
||||
connections,
|
||||
setSelected,
|
||||
setIsDetailModalOpen,
|
||||
close,
|
||||
visibleColumns,
|
||||
onColumnWidthChange,
|
||||
onSortChange,
|
||||
initialColumnWidths,
|
||||
initialSortColumn,
|
||||
initialSortDirection
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const tableRef = useRef<HTMLDivElement>(null)
|
||||
const [resizingColumn, setResizingColumn] = useState<string | null>(null)
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(initialSortColumn || null)
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(initialSortDirection || 'asc')
|
||||
|
||||
// 状态列渲染函数
|
||||
const renderStatus = useCallback((conn: IMihomoConnectionDetail) => (
|
||||
<Chip
|
||||
color={conn.isActive ? 'primary' : 'danger'}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="dot"
|
||||
>
|
||||
{conn.isActive ? t('connections.active') : t('connections.closed')}
|
||||
</Chip>
|
||||
), [t])
|
||||
|
||||
// 连接类型渲染函数
|
||||
const renderType = useCallback((conn: IMihomoConnectionDetail) => (
|
||||
<span className="text-xs">{conn.metadata.type}({conn.metadata.network.toUpperCase()})</span>
|
||||
), [])
|
||||
|
||||
// 翻译标签映射
|
||||
const getLabelForColumn = useCallback((key: string): string => {
|
||||
const translationMap: Record<string, string> = {
|
||||
status: t('connections.detail.status'),
|
||||
establishTime: t('connections.detail.establishTime'),
|
||||
type: t('connections.detail.connectionType'),
|
||||
host: t('connections.detail.host'),
|
||||
sniffHost: t('connections.detail.sniffHost'),
|
||||
process: t('connections.detail.processName'),
|
||||
processPath: t('connections.detail.processPath'),
|
||||
rule: t('connections.detail.rule'),
|
||||
proxyChain: t('connections.detail.proxyChain'),
|
||||
sourceIP: t('connections.detail.sourceIP'),
|
||||
sourcePort: t('connections.detail.sourcePort'),
|
||||
destinationPort: t('connections.detail.destinationPort'),
|
||||
inboundIP: t('connections.detail.inboundIP'),
|
||||
inboundPort: t('connections.detail.inboundPort'),
|
||||
uploadSpeed: t('connections.uploadSpeed'),
|
||||
downloadSpeed: t('connections.downloadSpeed'),
|
||||
upload: t('connections.uploadAmount'),
|
||||
download: t('connections.downloadAmount'),
|
||||
dscp: t('connections.detail.dscp'),
|
||||
remoteDestination: t('connections.detail.remoteDestination'),
|
||||
dnsMode: t('connections.detail.dnsMode')
|
||||
}
|
||||
return translationMap[key] || key
|
||||
}, [t])
|
||||
|
||||
// 初始化列配置(保留宽度状态)
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
||||
const widths: Record<string, number> = {}
|
||||
DEFAULT_COLUMNS.forEach(col => {
|
||||
widths[col.key] = initialColumnWidths?.[col.key] || col.width
|
||||
})
|
||||
return widths
|
||||
})
|
||||
|
||||
// 更新列标签和可见性
|
||||
const columnsWithLabels = useMemo(() =>
|
||||
DEFAULT_COLUMNS.map(col => ({
|
||||
...col,
|
||||
label: getLabelForColumn(col.key),
|
||||
visible: visibleColumns.has(col.key),
|
||||
width: columnWidths[col.key] || col.width
|
||||
}))
|
||||
, [getLabelForColumn, visibleColumns, columnWidths])
|
||||
|
||||
// 处理列宽度调整
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent, columnKey: string) => {
|
||||
e.preventDefault()
|
||||
setResizingColumn(columnKey)
|
||||
|
||||
const startX = e.clientX
|
||||
const column = DEFAULT_COLUMNS.find(c => c.key === columnKey)
|
||||
if (!column) return
|
||||
|
||||
let currentWidth = column.width
|
||||
setColumnWidths(prev => {
|
||||
currentWidth = prev[columnKey] || column.width
|
||||
return prev
|
||||
})
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const diff = e.clientX - startX
|
||||
const newWidth = Math.max(column.minWidth, currentWidth + diff)
|
||||
|
||||
setColumnWidths(prev => ({
|
||||
...prev,
|
||||
[columnKey]: newWidth
|
||||
}))
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizingColumn(null)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
// 保存列宽度
|
||||
if (onColumnWidthChange) {
|
||||
setColumnWidths(currentWidths => {
|
||||
onColumnWidthChange(currentWidths)
|
||||
return currentWidths
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [onColumnWidthChange])
|
||||
|
||||
// 处理排序
|
||||
const handleSort = useCallback((columnKey: string) => {
|
||||
let newDirection: 'asc' | 'desc' = 'asc'
|
||||
let newColumn = columnKey
|
||||
|
||||
if (sortColumn === columnKey) {
|
||||
newDirection = sortDirection === 'asc' ? 'desc' : 'asc'
|
||||
setSortDirection(newDirection)
|
||||
} else {
|
||||
setSortColumn(columnKey)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
|
||||
// 保存排序状态
|
||||
if (onSortChange) {
|
||||
onSortChange(newColumn, newDirection)
|
||||
}
|
||||
}, [sortColumn, sortDirection, onSortChange])
|
||||
|
||||
// 排序连接
|
||||
const sortedConnections = useMemo(() => {
|
||||
if (!sortColumn) return connections
|
||||
|
||||
const column = columnsWithLabels.find(c => c.key === sortColumn)
|
||||
if (!column) return connections
|
||||
|
||||
return [...connections].sort((a, b) => {
|
||||
const getSortValue = column.sortValue || column.getValue
|
||||
const aValue = getSortValue(a)
|
||||
const bValue = getSortValue(b)
|
||||
|
||||
let comparison = 0
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
comparison = aValue.localeCompare(bValue)
|
||||
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
comparison = aValue - bValue
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}, [connections, sortColumn, sortDirection, columnsWithLabels])
|
||||
|
||||
const visibleColumnsFiltered = columnsWithLabels.filter(col => col.visible)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 表格容器 */}
|
||||
<div ref={tableRef} className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-content2">
|
||||
<tr>
|
||||
{visibleColumnsFiltered.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="relative border-b border-divider text-left text-xs font-semibold text-foreground-600 px-3 h-10"
|
||||
style={{ width: col.width, minWidth: col.minWidth }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<button
|
||||
className="flex-1 text-left hover:text-foreground"
|
||||
onClick={() => handleSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
{sortColumn === col.key && (
|
||||
<span className="ml-1">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-2 cursor-col-resize flex items-center justify-center group"
|
||||
onMouseDown={(e) => handleMouseDown(e, col.key)}
|
||||
>
|
||||
<div
|
||||
className="w-px h-full bg-divider group-hover:bg-primary transition-colors"
|
||||
style={{
|
||||
backgroundColor: resizingColumn === col.key ? 'var(--primary)' : undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="sticky right-0 border-b border-divider w-12 bg-content2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedConnections.map((connection, index) => (
|
||||
<tr
|
||||
key={connection.id}
|
||||
className="border-b border-divider hover:bg-content2 cursor-pointer transition-colors h-12"
|
||||
onClick={() => {
|
||||
setSelected(connection)
|
||||
setIsDetailModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{visibleColumnsFiltered.map(col => {
|
||||
let content: React.ReactNode
|
||||
// 根据列类型选择渲染方式
|
||||
if (col.key === 'status') {
|
||||
content = renderStatus(connection)
|
||||
} else if (col.key === 'type') {
|
||||
content = renderType(connection)
|
||||
} else if (col.render) {
|
||||
content = col.render(connection)
|
||||
} else {
|
||||
content = col.getValue(connection)
|
||||
}
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className="px-3 text-sm text-foreground truncate"
|
||||
style={{ maxWidth: col.width }}
|
||||
title={typeof col.getValue(connection) === 'string' ? col.getValue(connection) as string : ''}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="sticky right-0 bg-inherit" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
color={connection.isActive ? 'warning' : 'danger'}
|
||||
variant="light"
|
||||
isIconOnly
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
close(connection.id)
|
||||
}}
|
||||
>
|
||||
{connection.isActive ? <CgClose className="text-lg" /> : <CgTrash className="text-lg" />}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sortedConnections.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-foreground-400">
|
||||
{t('connections.table.noData')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectionTable
|
||||
@ -540,8 +540,13 @@
|
||||
"connections.detail.specialProxy": "Special Proxy",
|
||||
"connections.detail.specialRules": "Special Rules",
|
||||
"connections.detail.close": "Close",
|
||||
"connections.detail.status": "Status",
|
||||
"connections.pause": "Pause",
|
||||
"connections.resume": "Resume",
|
||||
"connections.table.switchToTable": "Switch to Table View",
|
||||
"connections.table.switchToList": "Switch to List View",
|
||||
"connections.table.columns": "Columns",
|
||||
"connections.table.noData": "No Data",
|
||||
"resources.geoData.geoip": "GeoIP Database",
|
||||
"resources.geoData.geosite": "GeoSite Database",
|
||||
"resources.geoData.mmdb": "MMDB Database",
|
||||
|
||||
@ -540,8 +540,13 @@
|
||||
"connections.detail.specialProxy": "特殊代理",
|
||||
"connections.detail.specialRules": "特殊规则",
|
||||
"connections.detail.close": "关闭",
|
||||
"connections.detail.status": "状态",
|
||||
"connections.pause": "暂停",
|
||||
"connections.resume": "恢复",
|
||||
"connections.table.switchToTable": "切换到表格视图",
|
||||
"connections.table.switchToList": "切换到列表视图",
|
||||
"connections.table.columns": "显示列",
|
||||
"connections.table.noData": "暂无数据",
|
||||
"resources.geoData.geoip": "GeoIP 数据库",
|
||||
"resources.geoData.geosite": "GeoSite 数据库",
|
||||
"resources.geoData.mmdb": "MMDB 数据库",
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import BasePage from '@renderer/components/base/base-page'
|
||||
import { mihomoCloseAllConnections, mihomoCloseConnection } from '@renderer/utils/ipc'
|
||||
import { Key, useEffect, useMemo, useState } from 'react'
|
||||
import { Key, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Badge, Button, Divider, Input, Select, SelectItem, Tab, Tabs } from '@heroui/react'
|
||||
import { calcTraffic } from '@renderer/utils/calc'
|
||||
import ConnectionItem from '@renderer/components/connections/connection-item'
|
||||
import ConnectionTable from '@renderer/components/connections/connection-table'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import dayjs from '@renderer/utils/dayjs'
|
||||
import ConnectionDetailModal from '@renderer/components/connections/connection-detail-modal'
|
||||
import { CgClose, CgTrash } from 'react-icons/cg'
|
||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||
import { HiSortAscending, HiSortDescending } from 'react-icons/hi'
|
||||
import { MdViewList, MdTableChart } from 'react-icons/md'
|
||||
import { HiOutlineAdjustmentsHorizontal } from 'react-icons/hi2'
|
||||
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react'
|
||||
import { includesIgnoreCase } from '@renderer/utils/includes'
|
||||
import { differenceWith, unionWith } from 'lodash'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -21,7 +25,18 @@ const Connections: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState('')
|
||||
const { appConfig, patchAppConfig } = useAppConfig()
|
||||
const { connectionDirection = 'asc', connectionOrderBy = 'time' } = appConfig || {}
|
||||
const {
|
||||
connectionDirection = 'asc',
|
||||
connectionOrderBy = 'time',
|
||||
connectionViewMode = 'list',
|
||||
connectionTableColumns = [
|
||||
'status', 'establishTime', 'type', 'host', 'process', 'rule',
|
||||
'proxyChain', 'remoteDestination', 'uploadSpeed', 'downloadSpeed', 'upload', 'download'
|
||||
],
|
||||
connectionTableColumnWidths,
|
||||
connectionTableSortColumn,
|
||||
connectionTableSortDirection
|
||||
} = appConfig || {}
|
||||
const [connectionsInfo, setConnectionsInfo] = useState<IMihomoConnectionsInfo>()
|
||||
const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections)
|
||||
const [activeConnections, setActiveConnections] = useState<IMihomoConnectionDetail[]>([])
|
||||
@ -30,54 +45,64 @@ const Connections: React.FC = () => {
|
||||
const [selected, setSelected] = useState<IMihomoConnectionDetail>()
|
||||
const [tab, setTab] = useState('active')
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'list' | 'table'>(connectionViewMode)
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(new Set(connectionTableColumns))
|
||||
|
||||
const handleColumnWidthChange = useCallback(async (widths: Record<string, number>) => {
|
||||
await patchAppConfig({ connectionTableColumnWidths: widths })
|
||||
}, [patchAppConfig])
|
||||
|
||||
const handleSortChange = useCallback(async (column: string | null, direction: 'asc' | 'desc') => {
|
||||
await patchAppConfig({
|
||||
connectionTableSortColumn: column || undefined,
|
||||
connectionTableSortDirection: direction
|
||||
})
|
||||
}, [patchAppConfig])
|
||||
|
||||
const filteredConnections = useMemo(() => {
|
||||
const connections = tab === 'active' ? activeConnections : closedConnections
|
||||
if (connectionOrderBy) {
|
||||
connections.sort((a, b) => {
|
||||
if (connectionDirection === 'asc') {
|
||||
switch (connectionOrderBy) {
|
||||
case 'time':
|
||||
return dayjs(b.start).unix() - dayjs(a.start).unix()
|
||||
case 'upload':
|
||||
return a.upload - b.upload
|
||||
case 'download':
|
||||
return a.download - b.download
|
||||
case 'uploadSpeed':
|
||||
return (a.uploadSpeed || 0) - (b.uploadSpeed || 0)
|
||||
case 'downloadSpeed':
|
||||
return (a.downloadSpeed || 0) - (b.downloadSpeed || 0)
|
||||
}
|
||||
} else {
|
||||
switch (connectionOrderBy) {
|
||||
case 'time':
|
||||
return dayjs(a.start).unix() - dayjs(b.start).unix()
|
||||
case 'upload':
|
||||
return b.upload - a.upload
|
||||
case 'download':
|
||||
return b.download - a.download
|
||||
case 'uploadSpeed':
|
||||
return (b.uploadSpeed || 0) - (a.uploadSpeed || 0)
|
||||
case 'downloadSpeed':
|
||||
return (b.downloadSpeed || 0) - (a.downloadSpeed || 0)
|
||||
}
|
||||
|
||||
const filtered = filter === ''
|
||||
? connections
|
||||
: connections.filter((connection) => {
|
||||
const raw = JSON.stringify(connection)
|
||||
return includesIgnoreCase(raw, filter)
|
||||
})
|
||||
|
||||
if (viewMode === 'list' && connectionOrderBy) {
|
||||
return [...filtered].sort((a, b) => {
|
||||
let comparison = 0
|
||||
switch (connectionOrderBy) {
|
||||
case 'time':
|
||||
comparison = dayjs(a.start).unix() - dayjs(b.start).unix()
|
||||
break
|
||||
case 'upload':
|
||||
comparison = a.upload - b.upload
|
||||
break
|
||||
case 'download':
|
||||
comparison = a.download - b.download
|
||||
break
|
||||
case 'uploadSpeed':
|
||||
comparison = (a.uploadSpeed || 0) - (b.uploadSpeed || 0)
|
||||
break
|
||||
case 'downloadSpeed':
|
||||
comparison = (a.downloadSpeed || 0) - (b.downloadSpeed || 0)
|
||||
break
|
||||
}
|
||||
return connectionDirection === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
if (filter === '') return connections
|
||||
return connections?.filter((connection) => {
|
||||
const raw = JSON.stringify(connection)
|
||||
return includesIgnoreCase(raw, filter)
|
||||
})
|
||||
}, [activeConnections, closedConnections, filter, connectionDirection, connectionOrderBy])
|
||||
|
||||
const closeAllConnections = (): void => {
|
||||
return filtered
|
||||
}, [activeConnections, closedConnections, tab, filter, connectionDirection, connectionOrderBy, viewMode])
|
||||
|
||||
const closeAllConnections = useCallback((): void => {
|
||||
tab === 'active' ? mihomoCloseAllConnections() : trashAllClosedConnection()
|
||||
}
|
||||
}, [tab, closedConnections])
|
||||
|
||||
const closeConnection = (id: string): void => {
|
||||
const closeConnection = useCallback((id: string): void => {
|
||||
tab === 'active' ? mihomoCloseConnection(id) : trashClosedConnection(id)
|
||||
}
|
||||
}, [tab])
|
||||
|
||||
const trashAllClosedConnection = (): void => {
|
||||
const trashIds = closedConnections.map((conn) => conn.id)
|
||||
@ -159,6 +184,20 @@ const Connections: React.FC = () => {
|
||||
showOutline={false}
|
||||
content={`${filteredConnections.length}`}
|
||||
>
|
||||
<Button
|
||||
className="app-nodrag ml-1"
|
||||
title={viewMode === 'list' ? t('connections.table.switchToTable') : t('connections.table.switchToList')}
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={async () => {
|
||||
const newMode = viewMode === 'list' ? 'table' : 'list'
|
||||
setViewMode(newMode)
|
||||
await patchAppConfig({ connectionViewMode: newMode })
|
||||
}}
|
||||
>
|
||||
{viewMode === 'list' ? <MdTableChart className="text-lg" /> : <MdViewList className="text-lg" />}
|
||||
</Button>
|
||||
<Button
|
||||
className="app-nodrag ml-1"
|
||||
title={isPaused ? t('connections.resume') : t('connections.pause')}
|
||||
@ -246,64 +285,130 @@ const Connections: React.FC = () => {
|
||||
onValueChange={setFilter}
|
||||
/>
|
||||
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
size="sm"
|
||||
className="w-[180px] min-w-[131px]"
|
||||
aria-label={t('connections.orderBy')}
|
||||
selectedKeys={new Set([connectionOrderBy])}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
await patchAppConfig({
|
||||
connectionOrderBy: v.currentKey as
|
||||
| 'time'
|
||||
| 'upload'
|
||||
| 'download'
|
||||
| 'uploadSpeed'
|
||||
| 'downloadSpeed'
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectItem key="time">{t('connections.time')}</SelectItem>
|
||||
<SelectItem key="upload">{t('connections.uploadAmount')}</SelectItem>
|
||||
<SelectItem key="download">{t('connections.downloadAmount')}</SelectItem>
|
||||
<SelectItem key="uploadSpeed">{t('connections.uploadSpeed')}</SelectItem>
|
||||
<SelectItem key="downloadSpeed">{t('connections.downloadSpeed')}</SelectItem>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
className="bg-content2"
|
||||
onPress={async () => {
|
||||
patchAppConfig({
|
||||
connectionDirection: connectionDirection === 'asc' ? 'desc' : 'asc'
|
||||
})
|
||||
}}
|
||||
>
|
||||
{connectionDirection === 'asc' ? (
|
||||
<HiSortAscending className="text-lg" />
|
||||
) : (
|
||||
<HiSortDescending className="text-lg" />
|
||||
)}
|
||||
</Button>
|
||||
{viewMode === 'table' && (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
startContent={<HiOutlineAdjustmentsHorizontal className="text-2xl" />}
|
||||
>
|
||||
{t('connections.table.columns')}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="Column visibility"
|
||||
closeOnSelect={false}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={visibleColumns}
|
||||
onSelectionChange={async (keys) => {
|
||||
const newColumns = Array.from(keys) as string[]
|
||||
setVisibleColumns(new Set(newColumns))
|
||||
await patchAppConfig({ connectionTableColumns: newColumns })
|
||||
}}
|
||||
>
|
||||
<DropdownItem key="status">{t('connections.detail.status')}</DropdownItem>
|
||||
<DropdownItem key="establishTime">{t('connections.detail.establishTime')}</DropdownItem>
|
||||
<DropdownItem key="type">{t('connections.detail.connectionType')}</DropdownItem>
|
||||
<DropdownItem key="host">{t('connections.detail.host')}</DropdownItem>
|
||||
<DropdownItem key="sniffHost">{t('connections.detail.sniffHost')}</DropdownItem>
|
||||
<DropdownItem key="process">{t('connections.detail.processName')}</DropdownItem>
|
||||
<DropdownItem key="processPath">{t('connections.detail.processPath')}</DropdownItem>
|
||||
<DropdownItem key="rule">{t('connections.detail.rule')}</DropdownItem>
|
||||
<DropdownItem key="proxyChain">{t('connections.detail.proxyChain')}</DropdownItem>
|
||||
<DropdownItem key="sourceIP">{t('connections.detail.sourceIP')}</DropdownItem>
|
||||
<DropdownItem key="sourcePort">{t('connections.detail.sourcePort')}</DropdownItem>
|
||||
<DropdownItem key="destinationPort">{t('connections.detail.destinationPort')}</DropdownItem>
|
||||
<DropdownItem key="inboundIP">{t('connections.detail.inboundIP')}</DropdownItem>
|
||||
<DropdownItem key="inboundPort">{t('connections.detail.inboundPort')}</DropdownItem>
|
||||
<DropdownItem key="uploadSpeed">{t('connections.uploadSpeed')}</DropdownItem>
|
||||
<DropdownItem key="downloadSpeed">{t('connections.downloadSpeed')}</DropdownItem>
|
||||
<DropdownItem key="upload">{t('connections.uploadAmount')}</DropdownItem>
|
||||
<DropdownItem key="download">{t('connections.downloadAmount')}</DropdownItem>
|
||||
<DropdownItem key="dscp">{t('connections.detail.dscp')}</DropdownItem>
|
||||
<DropdownItem key="remoteDestination">{t('connections.detail.remoteDestination')}</DropdownItem>
|
||||
<DropdownItem key="dnsMode">{t('connections.detail.dnsMode')}</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
<Select
|
||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
||||
size="sm"
|
||||
className="w-[180px] min-w-[131px]"
|
||||
aria-label={t('connections.orderBy')}
|
||||
selectedKeys={new Set([connectionOrderBy])}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
await patchAppConfig({
|
||||
connectionOrderBy: v.currentKey as
|
||||
| 'time'
|
||||
| 'upload'
|
||||
| 'download'
|
||||
| 'uploadSpeed'
|
||||
| 'downloadSpeed'
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectItem key="time">{t('connections.time')}</SelectItem>
|
||||
<SelectItem key="upload">{t('connections.uploadAmount')}</SelectItem>
|
||||
<SelectItem key="download">{t('connections.downloadAmount')}</SelectItem>
|
||||
<SelectItem key="uploadSpeed">{t('connections.uploadSpeed')}</SelectItem>
|
||||
<SelectItem key="downloadSpeed">{t('connections.downloadSpeed')}</SelectItem>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
className="bg-content2"
|
||||
onPress={async () => {
|
||||
patchAppConfig({
|
||||
connectionDirection: connectionDirection === 'asc' ? 'desc' : 'asc'
|
||||
})
|
||||
}}
|
||||
>
|
||||
{connectionDirection === 'asc' ? (
|
||||
<HiSortAscending className="text-lg" />
|
||||
) : (
|
||||
<HiSortDescending className="text-lg" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
</div>
|
||||
<div className="h-[calc(100vh-100px)] mt-px">
|
||||
<Virtuoso
|
||||
data={filteredConnections}
|
||||
itemContent={(i, connection) => (
|
||||
<ConnectionItem
|
||||
setSelected={setSelected}
|
||||
setIsDetailModalOpen={setIsDetailModalOpen}
|
||||
selected={selected}
|
||||
close={closeConnection}
|
||||
index={i}
|
||||
key={connection.id}
|
||||
info={connection}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{viewMode === 'list' ? (
|
||||
<Virtuoso
|
||||
data={filteredConnections}
|
||||
itemContent={(i, connection) => (
|
||||
<ConnectionItem
|
||||
setSelected={setSelected}
|
||||
setIsDetailModalOpen={setIsDetailModalOpen}
|
||||
selected={selected}
|
||||
close={closeConnection}
|
||||
index={i}
|
||||
key={connection.id}
|
||||
info={connection}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ConnectionTable
|
||||
connections={filteredConnections}
|
||||
setSelected={setSelected}
|
||||
setIsDetailModalOpen={setIsDetailModalOpen}
|
||||
close={closeConnection}
|
||||
visibleColumns={visibleColumns}
|
||||
initialColumnWidths={connectionTableColumnWidths}
|
||||
initialSortColumn={connectionTableSortColumn}
|
||||
initialSortDirection={connectionTableSortDirection}
|
||||
onColumnWidthChange={handleColumnWidthChange}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BasePage>
|
||||
)
|
||||
|
||||
5
src/shared/types.d.ts
vendored
5
src/shared/types.d.ts
vendored
@ -234,6 +234,11 @@ interface IAppConfig {
|
||||
proxyCols: 'auto' | '1' | '2' | '3' | '4'
|
||||
connectionDirection: 'asc' | 'desc'
|
||||
connectionOrderBy: 'time' | 'upload' | 'download' | 'uploadSpeed' | 'downloadSpeed'
|
||||
connectionViewMode?: 'list' | 'table'
|
||||
connectionTableColumns?: string[]
|
||||
connectionTableColumnWidths?: Record<string, number>
|
||||
connectionTableSortColumn?: string
|
||||
connectionTableSortDirection?: 'asc' | 'desc'
|
||||
spinFloatingIcon?: boolean
|
||||
disableTray?: boolean
|
||||
showFloatingWindow?: boolean
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user