2019-12-23 17:19:54 +08:00

278 lines
10 KiB
TypeScript

import React, { useEffect, useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react'
import { useBlockLayout, useResizeColumns, useTable } from 'react-table'
import { VariableSizeGrid as Grid, GridOnScrollProps, GridChildComponentProps } from 'react-window'
import AutoSizer from 'react-virtualized-auto-sizer'
import classnames from 'classnames'
import { Header, Card, Checkbox, Modal, useModal, Icon } from '@components'
import { containers } from '@stores'
import * as API from '@lib/request'
import { StreamReader } from '@lib/streamer'
import { useObject } from '@lib/hook'
import { noop } from '@lib/helper'
import { fromNow } from '@lib/date'
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',
Time = 'time'
}
const columnsPair: [string, number][] = [
[Columns.Host, 260],
[Columns.Network, 80],
[Columns.Type, 120],
[Columns.Chains, 200],
[Columns.Rule, 140],
[Columns.Speed, 200],
[Columns.Upload, 100],
[Columns.Download, 100],
[Columns.Time, 120]
]
const shouldCenter = new Set<string>([Columns.Network, Columns.Type, Columns.Rule, Columns.Speed, Columns.Upload, Columns.Download, Columns.Time])
const couldSort = new Set<string>([Columns.Host, Columns.Network, Columns.Type, Columns.Rule, Columns.Upload, Columns.Download])
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`
}
}
export default function Connections () {
const { useTranslation, lang } = containers.useI18n()
const { t } = useTranslation('Connections')
// total
const [traffic, setTraffic] = useObject({
uploadTotal: 0,
downloadTotal: 0
})
// sort
const [sort, setSort] = useObject({
column: '',
asc: true
})
function handleSort (column: string) {
if (column === sort.column) {
sort.asc
? setSort('asc', false)
: setSort({ column: '', asc: true })
} else {
setSort('column', column)
}
}
// close all connections
const { visible, show, hide } = useModal()
function handleCloseConnections () {
API.closeAllConnections().finally(() => hide())
}
// connections
const { connections, feed, save, toggleSave } = useConnections()
const data = useMemo(() => {
return connections
.sort((a, b) => {
if (a.completed !== b.completed) {
return a.completed ? 1 : -1
}
const diffTime = new Date(a.start).getTime() - new Date(b.start).getTime()
if (diffTime !== 0) {
return diffTime
}
return a.id.localeCompare(b.id)
})
.map(c => ({
id: c.id,
host: `${ c.metadata.host || c.metadata.destinationIP }:${ c.metadata.destinationPort }`,
chains: c.chains.slice().reverse().join(' --> '),
rule: c.rule,
time: fromNow(new Date(c.start), lang),
upload: formatTraffic(c.upload),
download: formatTraffic(c.download),
type: c.metadata.type,
network: c.metadata.network.toUpperCase(),
speed: formatSpeed(c.speed.upload, c.speed.download),
completed: !!c.completed
}))
.sort((a, b) => {
if (sort.column !== '') {
return sort.asc
? a[sort.column].localeCompare(b[sort.column])
: b[sort.column].localeCompare(a[sort.column])
}
return 0
})
}, [connections, sort])
// table
const columns = useMemo(() => columnsPair.map(
c => ({
Header: t(`columns.${c[0]}`),
accessor: c[0],
minWidth: c[1],
width: c[1]
})
), [lang, t])
useEffect(() => {
let streamReader: StreamReader<API.Snapshot> = null
function handleConnection (snapshots: API.Snapshot[]) {
for (const snapshot of snapshots) {
setTraffic({
uploadTotal: snapshot.uploadTotal,
downloadTotal: snapshot.downloadTotal
})
feed(snapshot.connections)
}
}
void async function () {
const streamReader = await API.getConnectionStreamReader()
streamReader.subscribe('data', handleConnection)
}()
return () => {
if (streamReader) {
streamReader.unsubscribe('data', handleConnection)
streamReader.destory()
}
}
}, [])
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
columns: realColumns,
totalColumnsWidth
} = useTable(
{ columns, data },
useBlockLayout,
useResizeColumns
)
const headerGroup = useMemo(() => headerGroups[0], [headerGroups])
const renderItem = useCallback(
({ columnIndex, rowIndex, style }: GridChildComponentProps) => {
const row = rows[rowIndex]
prepareRow(row)
const cell = row.cells[columnIndex]
const classname = classnames(
'connections-block',
{ center: shouldCenter.has(cell.column.id), completed: !!(row.original as any).completed }
)
return (
<div {...row.getRowProps({ style })} className="connections-item">
<div {...cell.getCellProps()} className={classname}>
{ cell.render('Cell') }
</div>
</div>
)
},
[prepareRow, rows]
)
// handle consistency of react-window and react-table
const [girdLeft, setGirdLeft] = useState(0)
const handleScroll = useCallback(({ scrollLeft }: GridOnScrollProps) => setGirdLeft(scrollLeft), [setGirdLeft])
const handleColumnWidth = useCallback(index => realColumns[index].width, [realColumns])
const gridRef = useRef<Grid>()
useLayoutEffect(() => {
gridRef.current && gridRef.current.resetAfterIndices({
columnIndex: 0,
rowIndex: 0,
shouldForceUpdate: false
})
}, [totalColumnsWidth])
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>
<Card className="connections-card">
<div {...getTableProps()} className="connections">
<div {...headerGroup.getHeaderGroupProps()} className="connections-tr" style={{ transform: `translateX(-${girdLeft}px)` }}>
{
headerGroup.headers.map((column, idx) => {
const id = column.id
const handleClick = couldSort.has(id) ? () => handleSort(id) : noop
return (
<div {...column.getHeaderProps()} className="connections-th" onClick={handleClick}>
{ column.render('Header') }
{
sort.column === id && (sort.asc ? ' ↑' : ' ↓')
}
{ idx !== headerGroup.headers.length - 1 &&
<div {...(column as any).getResizerProps()} className="connections-resizer" />
}
</div>
)
})
}
</div>
<div {...getTableBodyProps()} className="connections-body">
<AutoSizer>
{
({ height, width }) => (
<Grid
ref={gridRef}
onScroll={handleScroll}
itemData={data}
itemKey={({ rowIndex, columnIndex, data }) => `${data[rowIndex].id}/${columnIndex}`}
height={height}
width={width}
columnCount={columns.length}
columnWidth={handleColumnWidth}
rowCount={rows.length}
rowHeight={() => 36}>
{ renderItem }
</Grid>
)
}
</AutoSizer>
</div>
</div>
</Card>
<Modal title={ t('closeAll.title') } show={visible} onClose={hide} onOk={handleCloseConnections}>{ t('closeAll.content') }</Modal>
</div>
)
}