Feature: support connections

This commit is contained in:
Dreamacro 2019-10-27 21:51:17 +08:00
parent 1686f50b37
commit 73bf262728
21 changed files with 1559 additions and 976 deletions

1762
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,21 +28,21 @@
"contributors:generate": "all-contributors generate" "contributors:generate": "all-contributors generate"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.6.2", "@babel/cli": "^7.6.4",
"@babel/core": "^7.6.2", "@babel/core": "^7.6.4",
"@babel/preset-env": "^7.6.2", "@babel/preset-env": "^7.6.3",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.6.3",
"@hot-loader/react-dom": "^16.9.0", "@hot-loader/react-dom": "^16.10.2",
"@types/classnames": "^2.2.8", "@types/classnames": "^2.2.8",
"@types/lodash-es": "^4.17.3", "@types/lodash-es": "^4.17.3",
"@types/node": "^12.7.8", "@types/node": "^12.11.7",
"@types/react": "^16.9.3", "@types/react": "^16.9.11",
"@types/react-dom": "^16.9.1", "@types/react-dom": "^16.9.3",
"@types/react-router-dom": "^5.1.0", "@types/react-router-dom": "^5.1.0",
"@types/react-virtualized-auto-sizer": "^1.0.0", "@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/react-window": "^1.8.1", "@types/react-window": "^1.8.1",
"@types/semver": "^6.0.2", "@types/semver": "^6.2.0",
"autoprefixer": "^9.6.1", "autoprefixer": "^9.7.0",
"awesome-typescript-loader": "^5.2.1", "awesome-typescript-loader": "^5.2.1",
"babel-loader": "^8.0.6", "babel-loader": "^8.0.6",
"babel-preset-minify": "^0.5.1", "babel-preset-minify": "^0.5.1",
@ -53,38 +53,40 @@
"mini-css-extract-plugin": "^0.8.0", "mini-css-extract-plugin": "^0.8.0",
"offline-plugin": "^5.0.7", "offline-plugin": "^5.0.7",
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
"react-hot-loader": "^4.12.14", "react-hot-loader": "^4.12.15",
"sass": "^1.22.12", "sass": "^1.23.1",
"sass-loader": "^8.0.0", "sass-loader": "^8.0.0",
"style-loader": "^1.0.0", "style-loader": "^1.0.0",
"stylelint": "^11.0.0", "stylelint": "^11.1.1",
"stylelint-config-standard": "^19.0.0", "stylelint-config-standard": "^19.0.0",
"stylelint-webpack-plugin": "^0.10.5", "stylelint-webpack-plugin": "^1.0.3",
"terser-webpack-plugin": "^2.1.2", "terser-webpack-plugin": "^2.2.1",
"tslint": "^5.20.0", "tslint": "^5.20.0",
"tslint-config-standard": "^8.0.1", "tslint-config-standard": "^8.0.1",
"tslint-loader": "^3.6.0", "tslint-loader": "^3.6.0",
"typescript": "^3.6.3", "typescript": "^3.6.4",
"webpack": "^4.41.0", "webpack": "^4.41.2",
"webpack-cli": "^3.3.9", "webpack-cli": "^3.3.9",
"webpack-dev-middleware": "^3.7.2", "webpack-dev-middleware": "^3.7.2",
"webpack-dev-server": "^3.8.1", "webpack-dev-server": "^3.9.0",
"webpack-merge": "^4.2.2", "webpack-merge": "^4.2.2",
"webpack-pwa-manifest": "^4.0.0" "webpack-pwa-manifest": "^4.1.1"
}, },
"dependencies": { "dependencies": {
"axios": "^0.19.0", "axios": "^0.19.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"dayjs": "^1.8.16", "dayjs": "^1.8.16",
"eventemitter3": "^4.0.0", "eventemitter3": "^4.0.0",
"immer": "^4.0.0", "immer": "^4.0.2",
"lodash-es": "^4.17.15", "lodash-es": "^4.17.15",
"react": "^16.10.1", "react": "^16.11.0",
"react-dom": "^16.10.1", "react-dom": "^16.11.0",
"react-router-dom": "^5.1.1", "react-router-dom": "^5.1.2",
"react-table": "^7.0.0-beta.12",
"react-virtualized-auto-sizer": "^1.0.2", "react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"semver": "^6.3.0", "semver": "^6.3.0",
"timeago.js": "^4.0.1",
"unstated-next": "^1.1.0", "unstated-next": "^1.1.0",
"use-immer": "^0.3.4" "use-immer": "^0.3.4"
} }

View File

@ -0,0 +1,27 @@
import * as React from 'react'
import { BaseComponentProps } from '@models/BaseProps'
import { Icon } from '@components'
import { noop } from '@lib/helper'
import classnames from 'classnames'
import './style.scss'
interface CheckboxProps extends BaseComponentProps {
checked: boolean
onChange?: (checked: boolean) => void
}
export function Checkbox (props: CheckboxProps) {
const { className, checked = false, onChange = noop } = props
const classname = classnames('checkbox', { checked }, className)
function handleClick () {
onChange(!checked)
}
return (
<div className={classname} onClick={handleClick}>
<Icon className="checkbox-icon" type="check" size={18} />
<div>{ props.children }</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
@import '~@styles/variables';
$length: 18px;
$padding: 26px;
.checkbox {
display: flex;
position: relative;
padding-left: $padding;
cursor: pointer;
line-height: $length;
&::before {
content: '';
display: inline-block;
position: absolute;
left: 0;
top: 50%;
width: $length;
height: $length;
border-radius: 3px;
transition: background-color 0.3s ease;
transform: translateY(-$length / 2);
background-color: $color-white;
border: 1px solid $color-primary-lightly;
}
&.checked::before {
background-color: $color-primary;
}
}
.checkbox-icon {
position: absolute;
left: 0;
top: 50%;
color: $color-white;
line-height: $length;
transform: translateY(-$length / 2) scale(0.6);
font-weight: bold;
}

View File

@ -21,7 +21,9 @@
.operations { .operations {
display: flex; display: flex;
flex: 1;
align-items: center; align-items: center;
justify-content: flex-end;
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@ -1,4 +1,4 @@
import React, { useRef, useLayoutEffect } from 'react' import React, { useRef, useLayoutEffect, MouseEvent, useState } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { BaseComponentProps } from '@models' import { BaseComponentProps } from '@models'
@ -55,7 +55,7 @@ export function Modal (props: ModalProps) {
return () => document.body.removeChild(portalRef.current) return () => document.body.removeChild(portalRef.current)
}, []) }, [])
function handleMaskClick (e) { function handleMaskClick (e: MouseEvent) {
if (e.target === maskRef.current) { if (e.target === maskRef.current) {
onClose() onClose()
} }
@ -90,3 +90,17 @@ export function Modal (props: ModalProps) {
return createPortal(modal, portalRef.current) return createPortal(modal, portalRef.current)
} }
export function useModal () {
const [visible, setVisible] = useState(false)
function show () {
setVisible(true)
}
function hide () {
setVisible(false)
}
return { visible, show, hide }
}

View File

@ -28,7 +28,7 @@ $width: 32px;
background-color: $color-gray-dark; background-color: $color-gray-dark;
&::after { &::after {
background-color: #f6f6f6; background-color: lighten($color-primary-lightly, 0.1);
box-shadow: 0 0 8px rgba($color-gray-darken, 0.5); box-shadow: 0 0 8px rgba($color-gray-darken, 0.5);
} }
} }

View File

@ -12,3 +12,4 @@ export * from './Modal'
export * from './Alert' export * from './Alert'
export * from './Button' export * from './Button'
export * from './Message' export * from './Message'
export * from './Checkbox'

View File

@ -11,6 +11,7 @@ import Logs from '@containers/Logs'
import Rules from '@containers/Rules' import Rules from '@containers/Rules'
import Settings from '@containers/Settings' import Settings from '@containers/Settings'
import SlideBar from '@containers/Sidebar' import SlideBar from '@containers/Sidebar'
import Connections from '@containers/Connections'
import ExternalControllerModal from '@containers/ExternalControllerDrawer' import ExternalControllerModal from '@containers/ExternalControllerDrawer'
import { getLogsStreamReader } from '@lib/request' import { getLogsStreamReader } from '@lib/request'
@ -24,6 +25,7 @@ function App () {
{ path: '/proxies', name: 'Proxies', component: Proxies }, { path: '/proxies', name: 'Proxies', component: Proxies },
{ path: '/logs', name: 'Logs', component: Logs }, { path: '/logs', name: 'Logs', component: Logs },
{ path: '/rules', name: 'Rules', component: Rules, noMobile: true }, { path: '/rules', name: 'Rules', component: Rules, noMobile: true },
{ path: '/connections', name: 'Connections', component: Connections, noMobile: true },
{ path: '/settings', name: 'Settings', component: Settings } { path: '/settings', name: 'Settings', component: Settings }
] ]

View File

@ -0,0 +1,277 @@
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 { format } from 'timeago.js'
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 { 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: format(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>
)
}

View File

@ -0,0 +1,86 @@
import * as API from '@lib/request'
import { useState, useMemo, useRef } from 'react'
type Connections = API.Connections & { completed?: boolean, speed: { upload: number, download: number } }
class Store {
protected connections = new Map<string, Connections>()
protected saveDisconnection = false
appendToSet (connections: API.Connections[]) {
const mapping = connections.reduce(
(map, c) => map.set(c.id, c), new Map<string, API.Connections>()
)
for (const id of this.connections.keys()) {
if (!mapping.has(id)) {
if (!this.saveDisconnection) {
this.connections.delete(id)
} else {
const connection = this.connections.get(id)
connection.completed = true
connection.speed = { upload: 0, download: 0 }
}
}
}
for (const id of mapping.keys()) {
if (!this.connections.has(id)) {
this.connections.set(id, { ...mapping.get(id), speed: { upload: 0, download: 0 } })
continue
}
const c = this.connections.get(id)
const n = mapping.get(id)
this.connections.set(id, { ...n, speed: { upload: n.upload - c.upload, download: n.download - c.download } })
}
}
toggleSave () {
if (this.saveDisconnection) {
this.saveDisconnection = false
for (const id of this.connections.keys()) {
if (this.connections.get(id).completed) {
this.connections.delete(id)
}
}
} else {
this.saveDisconnection = true
}
return this.saveDisconnection
}
getConnections () {
return [...this.connections.values()]
}
}
export function useConnections () {
const store = useMemo(() => new Store(), [])
const shouldFlush = useRef(true)
const [connections, setConnections] = useState<Connections[]>([])
const [save, setSave] = useState<boolean>(false)
function feed (connections: API.Connections[]) {
store.appendToSet(connections)
if (shouldFlush.current) {
setConnections(store.getConnections())
}
shouldFlush.current = !shouldFlush.current
}
function toggleSave () {
const state = store.toggleSave()
setSave(state)
if (!state) {
setConnections(store.getConnections())
}
shouldFlush.current = true
}
return { connections, feed, toggleSave, save }
}

View File

@ -0,0 +1,115 @@
@import '~@styles/variables';
.connections-card {
display: flex;
flex-direction: column;
flex: 1;
margin-top: 10px;
padding: 0;
.connections {
display: flex;
flex-direction: column;
flex: 1 0 auto;
width: 100%;
overflow: hidden;
}
.connections-body {
flex: 1 0 auto;
}
.connections-th {
$height: 30px;
position: relative;
text-align: center;
color: $color-gray-darken;
background: #f3f6f9;
height: $height;
line-height: $height;
font-weight: 500;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.connections-resizer {
$padding: 8px;
$width: 20px;
position: absolute;
opacity: 0;
right: $width / -2;
top: $padding;
bottom: $padding;
width: $width;
transition: opacity 0.3s ease;
z-index: 10;
font-size: 14px;
font-weight: 300;
&::before {
content: '';
display: block;
position: absolute;
left: $width / 2;
transform: translateX(-2px);
width: 2px;
height: 100%;
background-color: rgba($color-gray-darken, 60%);
}
}
.connections-tr {
white-space: nowrap;
&:hover .connections-resizer {
opacity: 1;
}
}
.connetions-item {
height: 36px;
}
.connections-block {
display: flex;
align-items: center;
font-size: 14px;
line-height: 36px;
padding: 0 10px;
color: $color-primary-darken;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.center {
text-align: center;
}
&.completed {
background-color: darken(#f3f6f9, 3%);
color: rgba($color-primary-darken, 50%);
}
}
}
.connections-filter {
color: $color-primary-dark;
font-size: 14px;
line-height: 20px;
margin-left: 15px;
text-shadow: 0 0 6px rgba($color: $color-primary-dark, $alpha: 0.4);
cursor: pointer;
&.dangerous {
color: $color-red;
text-shadow: 0 0 6px rgba($color: $color-primary, $alpha: 0.2);
}
&.total {
flex: 1;
cursor: unset;
}
}

View File

@ -31,7 +31,7 @@ export default function Logs () {
const streamReader = await getLogsStreamReader() const streamReader = await getLogsStreamReader()
logsRef.current = streamReader.buffer() logsRef.current = streamReader.buffer()
setLogs(logsRef.current) setLogs(logsRef.current)
streamReader.subscribe<Log[]>('data', handleLog) streamReader.subscribe('data', handleLog)
}() }()
return () => streamReader && streamReader.unsubscribe('data', handleLog) return () => streamReader && streamReader.unsubscribe('data', handleLog)

View File

@ -104,7 +104,7 @@ export default function Settings () {
<span className="label">{t('labels.startAtLogin')}</span> <span className="label">{t('labels.startAtLogin')}</span>
</Col> </Col>
<Col span={8} className="value-column"> <Col span={8} className="value-column">
<Switch disabled={!isClashX} checked={startAtLogin} onChange={handleStartAtLoginChange} /> <Switch disabled={!info.isClashX} checked={startAtLogin} onChange={handleStartAtLoginChange} />
</Col> </Col>
</Col> </Col>
<Col span={12}> <Col span={12}>
@ -123,7 +123,7 @@ export default function Settings () {
</Col> </Col>
<Col span={8} className="value-column"> <Col span={8} className="value-column">
<Switch <Switch
disabled={!isClashX} disabled={!info.isClashX}
checked={systemProxy} checked={systemProxy}
onChange={handleSetSystemProxy} onChange={handleSetSystemProxy}
/> />

View File

@ -4,7 +4,8 @@ export default {
Overview: 'Overview', Overview: 'Overview',
Logs: 'Logs', Logs: 'Logs',
Rules: 'Rules', Rules: 'Rules',
Settings: 'Setting' Settings: 'Setting',
Connections: 'Connections'
}, },
Settings: { Settings: {
title: 'Settings', title: 'Settings',
@ -41,6 +42,30 @@ export default {
Rules: { Rules: {
title: 'Rules' title: 'Rules'
}, },
Connections: {
title: 'Connections',
keepClosed: 'Keep closed connections',
total: {
text: 'total',
upload: 'upload',
download: 'download'
},
closeAll: {
title: 'Warning',
content: 'This would close all connections'
},
columns: {
host: 'Host',
network: 'Network',
type: 'Type',
chains: 'Chains',
rule: 'Rule',
time: 'Time',
speed: 'Speed',
upload: 'Upload',
download: 'Download'
}
},
Proxies: { Proxies: {
title: 'Proxies', title: 'Proxies',
editDialog: { editDialog: {

View File

@ -4,7 +4,8 @@ export default {
Overview: '总览', Overview: '总览',
Logs: '日志', Logs: '日志',
Rules: '规则', Rules: '规则',
Settings: '设置' Settings: '设置',
Connections: '连接'
}, },
Settings: { Settings: {
title: '设置', title: '设置',
@ -41,6 +42,30 @@ export default {
Rules: { Rules: {
title: '规则' title: '规则'
}, },
Connections: {
title: '连接',
keepClosed: '保留关闭连接',
total: {
text: '总量',
upload: '上传',
download: '下载'
},
closeAll: {
title: '警告',
content: '将会关闭所有连接'
},
columns: {
host: '域名',
network: '网络',
type: '类型',
chains: '节点链',
rule: '规则',
time: '连接时间',
speed: '速率',
upload: '上传',
download: '下载'
}
},
Proxies: { Proxies: {
title: '代理', title: '代理',
editDialog: { editDialog: {

View File

@ -1,6 +1,9 @@
import { Draft } from 'immer' import { Draft } from 'immer'
import { useImmer } from 'use-immer' import { useImmer } from 'use-immer'
import { createContainer } from 'unstated-next' import { createContainer } from 'unstated-next'
import { useRef, useEffect } from 'react'
import { noop } from '@lib/helper'
export function useObject<T extends object> (initialValue: T) { export function useObject<T extends object> (initialValue: T) {
const [copy, rawSet] = useImmer(initialValue) const [copy, rawSet] = useImmer(initialValue)
@ -31,6 +34,24 @@ export function useObject<T extends object> (initialValue: T) {
return [copy, set] as [T, typeof set] return [copy, set] as [T, typeof set]
} }
export function useInterval (callback: () => void, delay: number) {
const savedCallback = useRef(noop)
useEffect(() => savedCallback.current = callback, [callback])
useEffect(
() => {
const handler = () => savedCallback.current()
if (delay !== null) {
const id = setInterval(handler, delay)
return () => clearInterval(id)
}
},
[delay]
)
}
type containerFn<Value, State = void> = (initialState?: State) => Value type containerFn<Value, State = void> = (initialState?: State) => Value
export function composeContainer<T, C extends containerFn<T>, U extends { [key: string]: C }, K extends keyof U> (mapping: U) { export function composeContainer<T, C extends containerFn<T>, U extends { [key: string]: C }, K extends keyof U> (mapping: U) {

View File

@ -1,4 +1,5 @@
import axios from 'axios' import axios from 'axios'
import semver from 'semver'
import { Partial, getLocalStorageItem, to } from '@lib/helper' import { Partial, getLocalStorageItem, to } from '@lib/helper'
import { isClashX, jsBridge } from '@lib/jsBridge' import { isClashX, jsBridge } from '@lib/jsBridge'
import { createAsyncSingleton } from '@lib/asyncSingleton' import { createAsyncSingleton } from '@lib/asyncSingleton'
@ -49,6 +50,30 @@ export interface Group {
history: History[] history: History[]
} }
export interface Snapshot {
uploadTotal: number
downloadTotal: number
connections: Connections[]
}
export interface Connections {
id: string
metadata: {
network: string
type: string
host: string
sourceIP: string
sourcePort: string
destinationPort: string
destinationIP?: string
}
upload: number
download: number
start: string
chains: string[]
rule: string
}
export const getInstance = createAsyncSingleton(async () => { export const getInstance = createAsyncSingleton(async () => {
const { const {
hostname, hostname,
@ -107,6 +132,11 @@ export async function getProxyDelay (name: string) {
}) })
} }
export async function closeAllConnections () {
const req = await getInstance()
return req.delete('connections')
}
export async function changeProxySelected (name: string, select: string) { export async function changeProxySelected (name: string, select: string) {
const req = await getInstance() const req = await getInstance()
return req.put<void>(`proxies/${name}`, { name: select }) return req.put<void>(`proxies/${name}`, { name: select })
@ -134,12 +164,23 @@ export async function getExternalControllerConfig () {
return { hostname, port, secret } return { hostname, port, secret }
} }
export const getLogsStreamReader = createAsyncSingleton(async function getLogsStreamReader () { export const getLogsStreamReader = createAsyncSingleton(async function () {
const externalController = await getExternalControllerConfig() const externalController = await getExternalControllerConfig()
const { data: config } = await getConfig() const { data: config } = await getConfig()
const [data, err] = await to(getVersion()) const [data, err] = await to(getVersion())
const version = err ? 'unkonwn version' : data.data.version const version = err ? 'unkonwn version' : data.data.version
const useWebsocket = semver.valid(version) && semver.gt(version, 'v0.15.0-52-gc384693')
const logUrl = `${location.protocol}//${externalController.hostname}:${externalController.port}/logs?level=${config['log-level']}` const logUrl = `${location.protocol}//${externalController.hostname}:${externalController.port}/logs?level=${config['log-level']}`
return new StreamReader<Log>({ url: logUrl, bufferLength: 200, token: externalController.secret, version }) return new StreamReader<Log>({ url: logUrl, bufferLength: 200, token: externalController.secret, useWebsocket })
})
export const getConnectionStreamReader = createAsyncSingleton(async function () {
const externalController = await getExternalControllerConfig()
const [data, err] = await to(getVersion())
const version = err ? 'unkonwn version' : data.data.version
const useWebsocket = !!version || true
const logUrl = `${location.protocol}//${externalController.hostname}:${externalController.port}/connections`
return new StreamReader<Snapshot>({ url: logUrl, bufferLength: 200, token: externalController.secret, useWebsocket })
}) })

View File

@ -1,10 +1,9 @@
import { to } from '@lib/helper' import { to } from '@lib/helper'
import semver from 'semver'
import EventEmitter from 'eventemitter3' import EventEmitter from 'eventemitter3'
export interface Config { export interface Config {
url: string url: string
version: string useWebsocket: boolean
token?: string token?: string
bufferLength?: number bufferLength?: number
retryInterval?: number retryInterval?: number
@ -26,11 +25,9 @@ export class StreamReader<T> {
config config
) )
if (semver.valid(config.version) && semver.gt(config.version, 'v0.15.0-52-gc384693')) { this.config.useWebsocket
this.websocketLoop() ? this.websocketLoop()
return : this.loop()
}
this.loop()
} }
protected websocketLoop () { protected websocketLoop () {
@ -102,11 +99,11 @@ export class StreamReader<T> {
} }
} }
subscribe<T> (event: string, callback: (data: T) => void) { subscribe (event: string, callback: (data: T[]) => void) {
this.EE.addListener(event, callback) this.EE.addListener(event, callback)
} }
unsubscribe<T> (event: string, callback: (data: T) => void) { unsubscribe (event: string, callback: (data: T[]) => void) {
this.EE.removeListener(event, callback) this.EE.removeListener(event, callback)
} }

View File

@ -22,7 +22,6 @@ body {
::-webkit-scrollbar { ::-webkit-scrollbar {
z-index: 11; z-index: 11;
width: 5px;
background: transparent; background: transparent;
&-thumb { &-thumb {
@ -30,6 +29,14 @@ body {
background: #2c8af8; background: #2c8af8;
} }
} }
::-webkit-scrollbar:vertical {
width: 6px;
}
::-webkit-scrollbar:horizontal {
height: 6px;
}
} }
.app { .app {

View File

@ -6,7 +6,7 @@
@font-face { @font-face {
font-family: "clash-iconfont"; font-family: "clash-iconfont";
src: url('//at.alicdn.com/t/font_841708_z4foe3sarr8.ttf?t=1567008313882') format('truetype'); src: url('//at.alicdn.com/t/font_841708_w4jpsvny2x.ttf?t=1572182107550') format('truetype');
} }
.clash-iconfont { .clash-iconfont {
@ -51,3 +51,5 @@
.icon-sort-descending::before { content: "\e8b4"; } .icon-sort-descending::before { content: "\e8b4"; }
.icon-sort-ascending::before { content: "\e8b5"; } .icon-sort-ascending::before { content: "\e8b5"; }
.icon-close-all::before { content: "\e71b"; }