mirror of
https://github.com/woodchen-ink/clash-and-dashboard.git
synced 2025-07-18 14:01:56 +08:00
Feature: support connections
This commit is contained in:
parent
1686f50b37
commit
73bf262728
1752
package-lock.json
generated
1752
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@ -28,21 +28,21 @@
|
||||
"contributors:generate": "all-contributors generate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.6.2",
|
||||
"@babel/core": "^7.6.2",
|
||||
"@babel/preset-env": "^7.6.2",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@hot-loader/react-dom": "^16.9.0",
|
||||
"@babel/cli": "^7.6.4",
|
||||
"@babel/core": "^7.6.4",
|
||||
"@babel/preset-env": "^7.6.3",
|
||||
"@babel/preset-react": "^7.6.3",
|
||||
"@hot-loader/react-dom": "^16.10.2",
|
||||
"@types/classnames": "^2.2.8",
|
||||
"@types/lodash-es": "^4.17.3",
|
||||
"@types/node": "^12.7.8",
|
||||
"@types/react": "^16.9.3",
|
||||
"@types/react-dom": "^16.9.1",
|
||||
"@types/node": "^12.11.7",
|
||||
"@types/react": "^16.9.11",
|
||||
"@types/react-dom": "^16.9.3",
|
||||
"@types/react-router-dom": "^5.1.0",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.0",
|
||||
"@types/react-window": "^1.8.1",
|
||||
"@types/semver": "^6.0.2",
|
||||
"autoprefixer": "^9.6.1",
|
||||
"@types/semver": "^6.2.0",
|
||||
"autoprefixer": "^9.7.0",
|
||||
"awesome-typescript-loader": "^5.2.1",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-preset-minify": "^0.5.1",
|
||||
@ -53,38 +53,40 @@
|
||||
"mini-css-extract-plugin": "^0.8.0",
|
||||
"offline-plugin": "^5.0.7",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"react-hot-loader": "^4.12.14",
|
||||
"sass": "^1.22.12",
|
||||
"react-hot-loader": "^4.12.15",
|
||||
"sass": "^1.23.1",
|
||||
"sass-loader": "^8.0.0",
|
||||
"style-loader": "^1.0.0",
|
||||
"stylelint": "^11.0.0",
|
||||
"stylelint": "^11.1.1",
|
||||
"stylelint-config-standard": "^19.0.0",
|
||||
"stylelint-webpack-plugin": "^0.10.5",
|
||||
"terser-webpack-plugin": "^2.1.2",
|
||||
"stylelint-webpack-plugin": "^1.0.3",
|
||||
"terser-webpack-plugin": "^2.2.1",
|
||||
"tslint": "^5.20.0",
|
||||
"tslint-config-standard": "^8.0.1",
|
||||
"tslint-loader": "^3.6.0",
|
||||
"typescript": "^3.6.3",
|
||||
"webpack": "^4.41.0",
|
||||
"typescript": "^3.6.4",
|
||||
"webpack": "^4.41.2",
|
||||
"webpack-cli": "^3.3.9",
|
||||
"webpack-dev-middleware": "^3.7.2",
|
||||
"webpack-dev-server": "^3.8.1",
|
||||
"webpack-dev-server": "^3.9.0",
|
||||
"webpack-merge": "^4.2.2",
|
||||
"webpack-pwa-manifest": "^4.0.0"
|
||||
"webpack-pwa-manifest": "^4.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.19.0",
|
||||
"classnames": "^2.2.6",
|
||||
"dayjs": "^1.8.16",
|
||||
"eventemitter3": "^4.0.0",
|
||||
"immer": "^4.0.0",
|
||||
"immer": "^4.0.2",
|
||||
"lodash-es": "^4.17.15",
|
||||
"react": "^16.10.1",
|
||||
"react-dom": "^16.10.1",
|
||||
"react-router-dom": "^5.1.1",
|
||||
"react": "^16.11.0",
|
||||
"react-dom": "^16.11.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-table": "^7.0.0-beta.12",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-window": "^1.8.5",
|
||||
"semver": "^6.3.0",
|
||||
"timeago.js": "^4.0.1",
|
||||
"unstated-next": "^1.1.0",
|
||||
"use-immer": "^0.3.4"
|
||||
}
|
||||
|
27
src/components/Checkbox/index.tsx
Normal file
27
src/components/Checkbox/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
41
src/components/Checkbox/style.scss
Normal file
41
src/components/Checkbox/style.scss
Normal 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;
|
||||
}
|
@ -21,7 +21,9 @@
|
||||
|
||||
.operations {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useRef, useLayoutEffect } from 'react'
|
||||
import React, { useRef, useLayoutEffect, MouseEvent, useState } from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { BaseComponentProps } from '@models'
|
||||
@ -55,7 +55,7 @@ export function Modal (props: ModalProps) {
|
||||
return () => document.body.removeChild(portalRef.current)
|
||||
}, [])
|
||||
|
||||
function handleMaskClick (e) {
|
||||
function handleMaskClick (e: MouseEvent) {
|
||||
if (e.target === maskRef.current) {
|
||||
onClose()
|
||||
}
|
||||
@ -90,3 +90,17 @@ export function Modal (props: ModalProps) {
|
||||
|
||||
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 }
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ $width: 32px;
|
||||
background-color: $color-gray-dark;
|
||||
|
||||
&::after {
|
||||
background-color: #f6f6f6;
|
||||
background-color: lighten($color-primary-lightly, 0.1);
|
||||
box-shadow: 0 0 8px rgba($color-gray-darken, 0.5);
|
||||
}
|
||||
}
|
||||
|
@ -12,3 +12,4 @@ export * from './Modal'
|
||||
export * from './Alert'
|
||||
export * from './Button'
|
||||
export * from './Message'
|
||||
export * from './Checkbox'
|
||||
|
@ -11,6 +11,7 @@ import Logs from '@containers/Logs'
|
||||
import Rules from '@containers/Rules'
|
||||
import Settings from '@containers/Settings'
|
||||
import SlideBar from '@containers/Sidebar'
|
||||
import Connections from '@containers/Connections'
|
||||
import ExternalControllerModal from '@containers/ExternalControllerDrawer'
|
||||
import { getLogsStreamReader } from '@lib/request'
|
||||
|
||||
@ -24,6 +25,7 @@ function App () {
|
||||
{ path: '/proxies', name: 'Proxies', component: Proxies },
|
||||
{ path: '/logs', name: 'Logs', component: Logs },
|
||||
{ path: '/rules', name: 'Rules', component: Rules, noMobile: true },
|
||||
{ path: '/connections', name: 'Connections', component: Connections, noMobile: true },
|
||||
{ path: '/settings', name: 'Settings', component: Settings }
|
||||
]
|
||||
|
||||
|
277
src/containers/Connections/index.tsx
Normal file
277
src/containers/Connections/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
86
src/containers/Connections/store.ts
Normal file
86
src/containers/Connections/store.ts
Normal 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 }
|
||||
}
|
115
src/containers/Connections/style.scss
Normal file
115
src/containers/Connections/style.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -31,7 +31,7 @@ export default function Logs () {
|
||||
const streamReader = await getLogsStreamReader()
|
||||
logsRef.current = streamReader.buffer()
|
||||
setLogs(logsRef.current)
|
||||
streamReader.subscribe<Log[]>('data', handleLog)
|
||||
streamReader.subscribe('data', handleLog)
|
||||
}()
|
||||
|
||||
return () => streamReader && streamReader.unsubscribe('data', handleLog)
|
||||
|
@ -104,7 +104,7 @@ export default function Settings () {
|
||||
<span className="label">{t('labels.startAtLogin')}</span>
|
||||
</Col>
|
||||
<Col span={8} className="value-column">
|
||||
<Switch disabled={!isClashX} checked={startAtLogin} onChange={handleStartAtLoginChange} />
|
||||
<Switch disabled={!info.isClashX} checked={startAtLogin} onChange={handleStartAtLoginChange} />
|
||||
</Col>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
@ -123,7 +123,7 @@ export default function Settings () {
|
||||
</Col>
|
||||
<Col span={8} className="value-column">
|
||||
<Switch
|
||||
disabled={!isClashX}
|
||||
disabled={!info.isClashX}
|
||||
checked={systemProxy}
|
||||
onChange={handleSetSystemProxy}
|
||||
/>
|
||||
|
@ -4,7 +4,8 @@ export default {
|
||||
Overview: 'Overview',
|
||||
Logs: 'Logs',
|
||||
Rules: 'Rules',
|
||||
Settings: 'Setting'
|
||||
Settings: 'Setting',
|
||||
Connections: 'Connections'
|
||||
},
|
||||
Settings: {
|
||||
title: 'Settings',
|
||||
@ -41,6 +42,30 @@ export default {
|
||||
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: {
|
||||
title: 'Proxies',
|
||||
editDialog: {
|
||||
|
@ -4,7 +4,8 @@ export default {
|
||||
Overview: '总览',
|
||||
Logs: '日志',
|
||||
Rules: '规则',
|
||||
Settings: '设置'
|
||||
Settings: '设置',
|
||||
Connections: '连接'
|
||||
},
|
||||
Settings: {
|
||||
title: '设置',
|
||||
@ -41,6 +42,30 @@ export default {
|
||||
Rules: {
|
||||
title: '规则'
|
||||
},
|
||||
Connections: {
|
||||
title: '连接',
|
||||
keepClosed: '保留关闭连接',
|
||||
total: {
|
||||
text: '总量',
|
||||
upload: '上传',
|
||||
download: '下载'
|
||||
},
|
||||
closeAll: {
|
||||
title: '警告',
|
||||
content: '将会关闭所有连接'
|
||||
},
|
||||
columns: {
|
||||
host: '域名',
|
||||
network: '网络',
|
||||
type: '类型',
|
||||
chains: '节点链',
|
||||
rule: '规则',
|
||||
time: '连接时间',
|
||||
speed: '速率',
|
||||
upload: '上传',
|
||||
download: '下载'
|
||||
}
|
||||
},
|
||||
Proxies: {
|
||||
title: '代理',
|
||||
editDialog: {
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { Draft } from 'immer'
|
||||
import { useImmer } from 'use-immer'
|
||||
import { createContainer } from 'unstated-next'
|
||||
import { useRef, useEffect } from 'react'
|
||||
|
||||
import { noop } from '@lib/helper'
|
||||
|
||||
export function useObject<T extends object> (initialValue: T) {
|
||||
const [copy, rawSet] = useImmer(initialValue)
|
||||
@ -31,6 +34,24 @@ export function useObject<T extends object> (initialValue: T) {
|
||||
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
|
||||
|
||||
export function composeContainer<T, C extends containerFn<T>, U extends { [key: string]: C }, K extends keyof U> (mapping: U) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import semver from 'semver'
|
||||
import { Partial, getLocalStorageItem, to } from '@lib/helper'
|
||||
import { isClashX, jsBridge } from '@lib/jsBridge'
|
||||
import { createAsyncSingleton } from '@lib/asyncSingleton'
|
||||
@ -49,6 +50,30 @@ export interface Group {
|
||||
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 () => {
|
||||
const {
|
||||
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) {
|
||||
const req = await getInstance()
|
||||
return req.put<void>(`proxies/${name}`, { name: select })
|
||||
@ -134,12 +164,23 @@ export async function getExternalControllerConfig () {
|
||||
return { hostname, port, secret }
|
||||
}
|
||||
|
||||
export const getLogsStreamReader = createAsyncSingleton(async function getLogsStreamReader () {
|
||||
export const getLogsStreamReader = createAsyncSingleton(async function () {
|
||||
const externalController = await getExternalControllerConfig()
|
||||
const { data: config } = await getConfig()
|
||||
const [data, err] = await to(getVersion())
|
||||
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']}`
|
||||
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 })
|
||||
})
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { to } from '@lib/helper'
|
||||
import semver from 'semver'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
export interface Config {
|
||||
url: string
|
||||
version: string
|
||||
useWebsocket: boolean
|
||||
token?: string
|
||||
bufferLength?: number
|
||||
retryInterval?: number
|
||||
@ -26,11 +25,9 @@ export class StreamReader<T> {
|
||||
config
|
||||
)
|
||||
|
||||
if (semver.valid(config.version) && semver.gt(config.version, 'v0.15.0-52-gc384693')) {
|
||||
this.websocketLoop()
|
||||
return
|
||||
}
|
||||
this.loop()
|
||||
this.config.useWebsocket
|
||||
? this.websocketLoop()
|
||||
: this.loop()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
unsubscribe<T> (event: string, callback: (data: T) => void) {
|
||||
unsubscribe (event: string, callback: (data: T[]) => void) {
|
||||
this.EE.removeListener(event, callback)
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,6 @@ body {
|
||||
|
||||
::-webkit-scrollbar {
|
||||
z-index: 11;
|
||||
width: 5px;
|
||||
background: transparent;
|
||||
|
||||
&-thumb {
|
||||
@ -30,6 +29,14 @@ body {
|
||||
background: #2c8af8;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:vertical {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:horizontal {
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.app {
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
@font-face {
|
||||
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 {
|
||||
@ -51,3 +51,5 @@
|
||||
.icon-sort-descending::before { content: "\e8b4"; }
|
||||
|
||||
.icon-sort-ascending::before { content: "\e8b5"; }
|
||||
|
||||
.icon-close-all::before { content: "\e71b"; }
|
||||
|
Loading…
x
Reference in New Issue
Block a user