mirror of
https://github.com/woodchen-ink/clash-and-dashboard.git
synced 2025-07-18 14:01:56 +08:00
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import React, { useMemo, useLayoutEffect, useCallback, useRef, useState } from 'react'
|
|
import { Cell, Column, ColumnInstance, TableInstance, TableOptions, useBlockLayout, useFilters, UseFiltersInstanceProps, UseFiltersOptions, useResizeColumns, UseResizeColumnsColumnProps, UseResizeColumnsOptions, useSortBy, UseSortByColumnOptions, UseSortByColumnProps, UseSortByOptions, useTable } from 'react-table'
|
|
import classnames from 'classnames'
|
|
import { useScroll } from 'react-use'
|
|
import { groupBy } from 'lodash'
|
|
import { Header, Card, Checkbox, Modal, Icon } from '@components'
|
|
import { useI18n } from '@stores'
|
|
import * as API from '@lib/request'
|
|
import { StreamReader } from '@lib/streamer'
|
|
import { useObject, useVisible } from '@lib/hook'
|
|
import { fromNow } from '@lib/date'
|
|
import { RuleType } from '@models'
|
|
import { Devices } from './Devices'
|
|
import { useConnections } from './store'
|
|
import './style.scss'
|
|
|
|
enum Columns {
|
|
Host = 'host',
|
|
Network = 'network',
|
|
Type = 'type',
|
|
Chains = 'chains',
|
|
Rule = 'rule',
|
|
Speed = 'speed',
|
|
Upload = 'upload',
|
|
Download = 'download',
|
|
SourceIP = 'sourceIP',
|
|
Time = 'time'
|
|
}
|
|
|
|
const shouldCenter = new Set<string>([Columns.Network, Columns.Type, Columns.Rule, Columns.Speed, Columns.Upload, Columns.Download, Columns.SourceIP, Columns.Time])
|
|
|
|
interface TableColumn<D extends object = {}> extends
|
|
ColumnInstance<D>,
|
|
UseSortByColumnProps<D>,
|
|
UseResizeColumnsColumnProps<D> {}
|
|
|
|
type TableColumnOption<D extends object = {}> =
|
|
Column<D> &
|
|
UseResizeColumnsOptions<D> &
|
|
UseSortByColumnOptions<D>
|
|
|
|
interface ITableOptions<D extends object = {}> extends
|
|
TableOptions<D>,
|
|
UseSortByOptions<D>,
|
|
UseFiltersOptions<D> {}
|
|
|
|
interface ITableInstance<D extends object = {}> extends
|
|
TableInstance<D>,
|
|
UseFiltersInstanceProps<D> {}
|
|
|
|
function formatTraffic(num: number) {
|
|
const s = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
let idx = 0
|
|
while (~~(num / 1024) && idx < s.length) {
|
|
num /= 1024
|
|
idx++
|
|
}
|
|
|
|
return `${idx === 0 ? num : num.toFixed(2)} ${s[idx]}`
|
|
}
|
|
|
|
function formatSpeed(upload: number, download: number) {
|
|
switch (true) {
|
|
case upload === 0 && download === 0:
|
|
return '-'
|
|
case upload !== 0 && download !== 0:
|
|
return `↑ ${formatTraffic(upload)}/s ↓ ${formatTraffic(download)}/s`
|
|
case upload !== 0:
|
|
return `↑ ${formatTraffic(upload)}/s`
|
|
default:
|
|
return `↓ ${formatTraffic(download)}/s`
|
|
}
|
|
}
|
|
|
|
interface formatConnection {
|
|
id: string
|
|
host: string
|
|
chains: string
|
|
rule: string
|
|
time: number
|
|
upload: number
|
|
download: number
|
|
type: string
|
|
network: string
|
|
sourceIP: string
|
|
speed: {
|
|
upload: number
|
|
download: number
|
|
}
|
|
completed: boolean
|
|
}
|
|
|
|
export default function Connections() {
|
|
const { translation, lang } = useI18n()
|
|
const t = useMemo(() => translation('Connections').t, [translation])
|
|
|
|
// total
|
|
const [traffic, setTraffic] = useObject({
|
|
uploadTotal: 0,
|
|
downloadTotal: 0
|
|
})
|
|
|
|
// close all connections
|
|
const { visible, show, hide } = useVisible()
|
|
function handleCloseConnections() {
|
|
API.closeAllConnections().finally(() => hide())
|
|
}
|
|
|
|
// connections
|
|
const { connections, feed, save, toggleSave } = useConnections()
|
|
const data: formatConnection[] = useMemo(() => connections.map(
|
|
c => ({
|
|
id: c.id,
|
|
host: `${c.metadata.host || c.metadata.destinationIP}:${c.metadata.destinationPort}`,
|
|
chains: c.chains.slice().reverse().join(' / '),
|
|
rule: c.rule === RuleType.RuleSet ? `${c.rule}(${c.rulePayload})` : c.rule,
|
|
time: new Date(c.start).getTime(),
|
|
upload: c.upload,
|
|
download: c.download,
|
|
sourceIP: c.metadata.sourceIP,
|
|
type: c.metadata.type,
|
|
network: c.metadata.network.toUpperCase(),
|
|
speed: { upload: c.uploadSpeed, download: c.downloadSpeed },
|
|
completed: !!c.completed
|
|
})
|
|
), [connections])
|
|
const devices = useMemo(() => {
|
|
const gb = groupBy(connections, 'metadata.sourceIP')
|
|
return Object.keys(gb).map(key => ({ label: key, number: gb[key].length })).sort((a, b) => a.label.localeCompare(b.label))
|
|
}, [connections])
|
|
|
|
// table
|
|
const tableRef = useRef<HTMLDivElement>(null)
|
|
const { x: scrollX } = useScroll(tableRef)
|
|
const columns: TableColumnOption<formatConnection>[] = useMemo(() => [
|
|
{ Header: t(`columns.${Columns.Host}`), accessor: Columns.Host, minWidth: 260, width: 260 },
|
|
{ Header: t(`columns.${Columns.Network}`), accessor: Columns.Network, minWidth: 80, width: 80 },
|
|
{ Header: t(`columns.${Columns.Type}`), accessor: Columns.Type, minWidth: 120, width: 120 },
|
|
{ Header: t(`columns.${Columns.Chains}`), accessor: Columns.Chains, minWidth: 200, width: 200 },
|
|
{ Header: t(`columns.${Columns.Rule}`), accessor: Columns.Rule, minWidth: 140, width: 140 },
|
|
{
|
|
id: Columns.Speed,
|
|
Header: t(`columns.${Columns.Speed}`),
|
|
accessor(originalRow: formatConnection) {
|
|
return [originalRow.speed.upload, originalRow.speed.download]
|
|
},
|
|
sortType(rowA, rowB) {
|
|
const speedA = rowA.original.speed
|
|
const speedB = rowB.original.speed
|
|
return speedA.download === speedB.download
|
|
? speedA.upload - speedB.upload
|
|
: speedA.download - speedB.download
|
|
},
|
|
minWidth: 200, width: 200,
|
|
sortDescFirst: true
|
|
},
|
|
{ Header: t(`columns.${Columns.Upload}`), accessor: Columns.Upload, minWidth: 100, width: 100, sortDescFirst: true },
|
|
{ Header: t(`columns.${Columns.Download}`), accessor: Columns.Download, minWidth: 100, width: 100, sortDescFirst: true },
|
|
{ Header: t(`columns.${Columns.SourceIP}`), accessor: Columns.SourceIP, minWidth: 140, width: 140 },
|
|
{ Header: t(`columns.${Columns.Time}`), accessor: Columns.Time, minWidth: 120, width: 120, sortType(rowA, rowB) { return rowB.original.time - rowA.original.time } },
|
|
] as TableColumnOption<formatConnection>[], [t])
|
|
|
|
useLayoutEffect(() => {
|
|
let streamReader: StreamReader<API.Snapshot> | null = null
|
|
|
|
function handleConnection(snapshots: API.Snapshot[]) {
|
|
for (const snapshot of snapshots) {
|
|
setTraffic({
|
|
uploadTotal: snapshot.uploadTotal,
|
|
downloadTotal: snapshot.downloadTotal
|
|
})
|
|
|
|
feed(snapshot.connections)
|
|
}
|
|
}
|
|
|
|
(async function () {
|
|
streamReader = await API.getConnectionStreamReader()
|
|
streamReader.subscribe('data', handleConnection)
|
|
}())
|
|
|
|
return () => {
|
|
if (streamReader) {
|
|
streamReader.unsubscribe('data', handleConnection)
|
|
streamReader.destory()
|
|
}
|
|
}
|
|
}, [feed, setTraffic])
|
|
|
|
const {
|
|
getTableProps,
|
|
getTableBodyProps,
|
|
headerGroups,
|
|
rows,
|
|
prepareRow,
|
|
setFilter
|
|
} = useTable(
|
|
{
|
|
columns,
|
|
data,
|
|
autoResetSortBy: false,
|
|
autoResetFilters: false,
|
|
initialState: { sortBy: [{ id: Columns.Time, desc: false }] }
|
|
} as ITableOptions<formatConnection>,
|
|
useResizeColumns,
|
|
useBlockLayout,
|
|
useFilters,
|
|
useSortBy
|
|
) as ITableInstance<formatConnection>
|
|
const headerGroup = useMemo(() => headerGroups[0], [headerGroups])
|
|
const renderCell = useCallback(function (cell: Cell<formatConnection>) {
|
|
switch (cell.column.id) {
|
|
case Columns.Speed:
|
|
return formatSpeed(cell.value[0], cell.value[1])
|
|
case Columns.Upload:
|
|
case Columns.Download:
|
|
return formatTraffic(cell.value)
|
|
case Columns.Time:
|
|
return fromNow(new Date(cell.value), lang)
|
|
default:
|
|
return cell.value
|
|
}
|
|
}, [lang])
|
|
|
|
// filter
|
|
const [device, setDevice] = useState('')
|
|
function handleDeviceSelected (label: string) {
|
|
setDevice(label)
|
|
setFilter?.(Columns.SourceIP, label)
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<Header title={t('title')}>
|
|
<span className="connections-filter total">
|
|
{`(${t('total.text')}: ${t('total.upload')} ${formatTraffic(traffic.uploadTotal)} ${t('total.download')} ${formatTraffic(traffic.downloadTotal)})`}
|
|
</span>
|
|
<Checkbox className="connections-filter" checked={save} onChange={toggleSave}>{t('keepClosed')}</Checkbox>
|
|
<Icon className="connections-filter dangerous" onClick={show} type="close-all" size={20} />
|
|
</Header>
|
|
{ devices.length > 1 && <Devices devices={devices} selected={device} onChange={handleDeviceSelected} /> }
|
|
<Card className="connections-card">
|
|
<div {...getTableProps()} className="connections" ref={tableRef}>
|
|
<div {...headerGroup.getHeaderGroupProps()} className="connections-header">
|
|
{
|
|
headerGroup.headers.map((column, idx) => {
|
|
const realColumn = column as unknown as TableColumn<formatConnection>
|
|
const id = realColumn.id
|
|
return (
|
|
<div
|
|
{...realColumn.getHeaderProps()}
|
|
className={classnames('connections-th', {
|
|
resizing: realColumn.isResizing,
|
|
fixed: scrollX > 0 && realColumn.id === Columns.Host
|
|
})}
|
|
key={id}>
|
|
<div {...realColumn.getSortByToggleProps()}>
|
|
{column.render('Header')}
|
|
{
|
|
realColumn.isSorted
|
|
? realColumn.isSortedDesc ? ' ↓' : ' ↑'
|
|
: null
|
|
}
|
|
</div>
|
|
{ idx !== headerGroup.headers.length - 1 &&
|
|
<div {...realColumn.getResizerProps()} className="connections-resizer" />
|
|
}
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
</div>
|
|
|
|
<div {...getTableBodyProps()} className="connections-body">
|
|
{
|
|
rows.map(row => {
|
|
prepareRow(row)
|
|
return (
|
|
<div {...row.getRowProps()} className="connections-item" key={row.original.id}>
|
|
{
|
|
row.cells.map(cell => {
|
|
const classname = classnames(
|
|
'connections-block',
|
|
{ center: shouldCenter.has(cell.column.id), completed: row.original.completed },
|
|
{ fixed: scrollX > 0 && cell.column.id === Columns.Host }
|
|
)
|
|
return (
|
|
<div {...cell.getCellProps()} className={classname} key={cell.column.id}>
|
|
{ renderCell(cell)}
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Modal title={t('closeAll.title')} show={visible} onClose={hide} onOk={handleCloseConnections}>{t('closeAll.content')}</Modal>
|
|
</div>
|
|
)
|
|
}
|