diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 44b83a8..3a5725c 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import React, { MouseEventHandler } from 'react' import classnames from 'classnames' import { BaseComponentProps } from '@models' import { noop } from '@lib/helper' @@ -6,18 +6,20 @@ import './style.scss' interface ButtonProps extends BaseComponentProps { type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning' - onClick?: React.MouseEventHandler + onClick?: MouseEventHandler + disiabled?: boolean } export function Button (props: ButtonProps) { - const { type = 'normal', onClick = noop, children, className, style } = props - const classname = classnames('button', `button-${type}`, className) + const { type = 'normal', onClick = noop, children, className, style, disiabled } = props + const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disiabled }) return ( ) } diff --git a/src/components/Button/style.scss b/src/components/Button/style.scss index 47f4bb4..eb3ba27 100644 --- a/src/components/Button/style.scss +++ b/src/components/Button/style.scss @@ -7,6 +7,10 @@ font-size: 14px; cursor: pointer; transition: all 150ms ease; + + &:focus { + outline: none; + } } .button-primary { @@ -84,3 +88,14 @@ box-shadow: 0 0 2px rgba($color: darken($color-orange, 5%), $alpha: 0.5); } } + +.button.button-disabled { + color: $color-gray-dark; + background: linear-gradient(135deg, $color-gray-light, darken($color-gray-light, 5%)); + box-shadow: 0 2px 8px rgba($color: darken($color-gray-light, 5%), $alpha: 0.5); + cursor: not-allowed; + + &:active { + box-shadow: 0 0 2px rgba($color: darken($color-gray-light, 5%), $alpha: 0.5); + } +} diff --git a/src/components/Card/index.tsx b/src/components/Card/index.tsx index 17c766f..7afe833 100644 --- a/src/components/Card/index.tsx +++ b/src/components/Card/index.tsx @@ -1,15 +1,17 @@ -import * as React from 'react' +import React, { forwardRef } from 'react' import { BaseComponentProps } from '@models/BaseProps' import classnames from 'classnames' import './style.scss' -type CardProps = BaseComponentProps +interface CardProps extends BaseComponentProps { + ref?: React.ForwardedRef +} -export function Card (props: CardProps) { +export const Card = forwardRef((props: CardProps, ref) => { const { className, style, children } = props return ( -
+
{ children }
) -} +}) diff --git a/src/components/Drawer/index.tsx b/src/components/Drawer/index.tsx new file mode 100644 index 0000000..eba668d --- /dev/null +++ b/src/components/Drawer/index.tsx @@ -0,0 +1,31 @@ +import React, { useLayoutEffect, useRef, RefObject } from 'react' +import { createPortal } from 'react-dom' +import classnames from 'classnames' +import { BaseComponentProps } from '@models/BaseProps' +import { Card } from '@components' + +interface DrawerProps extends BaseComponentProps { + visible?: boolean + width?: number + containerRef?: RefObject +} + +export function Drawer (props: DrawerProps) { + const portalRef = useRef(document.createElement('div')) + + useLayoutEffect(() => { + const current = portalRef.current + document.body.appendChild(current) + return () => { document.body.removeChild(current) } + }, []) + + const cardStyle = 'absolute h-full right-0 transition-transform transform translate-x-full duration-100 pointer-events-auto' + + const container = ( +
+ {props.children} +
+ ) + + return createPortal(container, props.containerRef?.current ?? portalRef.current) +} diff --git a/src/components/index.ts b/src/components/index.ts index b7d0ae0..67061a8 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -13,3 +13,4 @@ export * from './Message' export * from './Checkbox' export * from './Tag' export * from './Loading' +export * from './Drawer' diff --git a/src/containers/Connections/Info/index.tsx b/src/containers/Connections/Info/index.tsx new file mode 100644 index 0000000..3ce4c1f --- /dev/null +++ b/src/containers/Connections/Info/index.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from 'react' +import classnames from 'classnames' +import { useI18n } from '@stores' +import { formatTraffic } from '@lib/helper' +import { BaseComponentProps } from '@models' +import { Connection } from '../store' + +interface ConnectionsInfoProps extends BaseComponentProps { + connection: Partial +} + +export function ConnectionInfo (props: ConnectionsInfoProps) { + const { translation } = useI18n() + const t = useMemo(() => translation('Connections').t, [translation]) + + return ( +
+
+ {t('info.id')} + {props.connection.id} +
+
+
+ {t('info.network')} + {props.connection.metadata?.network} +
+
+ {t('info.inbound')} + {props.connection.metadata?.type} +
+
+
+ {t('info.host')} + { + props.connection.metadata?.host + ? `${props.connection.metadata.host}:${props.connection.metadata?.destinationPort}` + : t('info.hostEmpty') + } +
+
+ {t('info.dstIP')} + { + props.connection.metadata?.destinationIP + ? `${props.connection.metadata.destinationIP}:${props.connection.metadata?.destinationPort}` + : t('info.hostEmpty') + } +
+
+ {t('info.srcIP')} + { + `${props.connection.metadata?.sourceIP}:${props.connection.metadata?.sourcePort}` + } +
+
+ {t('info.rule')} + + { props.connection.rule && `${props.connection.rule}${props.connection.rulePayload && `(${props.connection.rulePayload})`}` } + +
+
+ {t('info.chains')} + + { props.connection.chains?.slice().reverse().join(' / ') } + +
+
+
+ {t('info.upload')} + {formatTraffic(props.connection.upload ?? 0)} +
+
+ {t('info.download')} + {formatTraffic(props.connection.download ?? 0)} +
+
+
+ {t('info.status')} + { + !props.connection.completed + ? {t('info.opening')} + : {t('info.closed')} + } +
+
+ ) +} diff --git a/src/containers/Connections/index.tsx b/src/containers/Connections/index.tsx index a93adf1..efcd9b2 100644 --- a/src/containers/Connections/index.tsx +++ b/src/containers/Connections/index.tsx @@ -1,16 +1,18 @@ -import React, { useMemo, useLayoutEffect, useCallback, useRef, useState } from 'react' +import React, { useMemo, useLayoutEffect, useCallback, useRef, useState, useEffect } 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 { useLatest, useScroll } from 'react-use' import { groupBy } from 'lodash-es' -import { Header, Card, Checkbox, Modal, Icon } from '@components' +import { Header, Checkbox, Modal, Icon, Drawer, Card, Button } from '@components' import { useClient, useConnectionStreamReader, useI18n } from '@stores' import * as API from '@lib/request' import { useObject, useVisible } from '@lib/hook' import { fromNow } from '@lib/date' +import { formatTraffic } from '@lib/helper' import { RuleType } from '@models' import { Devices } from './Devices' -import { useConnections } from './store' +import { ConnectionInfo } from './Info' +import { Connection, FormatConnection, useConnections } from './store' import './style.scss' enum Columns { @@ -47,17 +49,6 @@ interface ITableInstance extends TableInstance, UseFiltersInstanceProps {} -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: @@ -71,29 +62,12 @@ function formatSpeed (upload: number, download: number) { } } -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]) const connStreamReader = useConnectionStreamReader() const client = useClient() + const cardRef = useRef(null) // total const [traffic, setTraffic] = useObject({ @@ -109,7 +83,7 @@ export default function Connections () { // connections const { connections, feed, save, toggleSave } = useConnections() - const data: formatConnection[] = useMemo(() => connections.map( + const data: FormatConnection[] = useMemo(() => connections.map( c => ({ id: c.id, host: `${c.metadata.host || c.metadata.destinationIP}:${c.metadata.destinationPort}`, @@ -123,6 +97,7 @@ export default function Connections () { network: c.metadata.network.toUpperCase(), speed: { upload: c.uploadSpeed, download: c.downloadSpeed }, completed: !!c.completed, + original: c, }), ), [connections]) const devices = useMemo(() => { @@ -133,7 +108,7 @@ export default function Connections () { // table const tableRef = useRef(null) const { x: scrollX } = useScroll(tableRef) - const columns: Array> = useMemo(() => [ + const columns: Array> = 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 }, @@ -142,7 +117,7 @@ export default function Connections () { { id: Columns.Speed, Header: t(`columns.${Columns.Speed}`), - accessor (originalRow: formatConnection) { + accessor (originalRow: FormatConnection) { return [originalRow.speed.upload, originalRow.speed.download] }, sortType (rowA, rowB) { @@ -160,7 +135,7 @@ export default function Connections () { { 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 Array>, [t]) + ] as Array>, [t]) useLayoutEffect(() => { function handleConnection (snapshots: API.Snapshot[]) { @@ -195,14 +170,14 @@ export default function Connections () { autoResetSortBy: false, autoResetFilters: false, initialState: { sortBy: [{ id: Columns.Time, desc: false }] }, - } as ITableOptions, + } as ITableOptions, useResizeColumns, useBlockLayout, useFilters, useSortBy, - ) as ITableInstance + ) as ITableInstance const headerGroup = useMemo(() => headerGroups[0], [headerGroups]) - const renderCell = useCallback(function (cell: Cell) { + const renderCell = useCallback(function (cell: Cell) { switch (cell.column.id) { case Columns.Speed: return formatSpeed(cell.value[0], cell.value[1]) @@ -223,6 +198,37 @@ export default function Connections () { setFilter?.(Columns.SourceIP, label) } + // click item + const [drawerState, setDrawerState] = useObject({ + visible: false, + selectedID: '', + connection: {} as Partial, + }) + function handleConnectionSelected (id: string) { + setDrawerState({ + visible: true, + selectedID: id, + }) + } + function handleConnectionClosed () { + setDrawerState(d => { d.connection.completed = true }) + client.closeConnection(drawerState.selectedID) + } + const latestConntion = useLatest(drawerState.connection) + useEffect(() => { + const conn = data.find(c => c.id === drawerState.selectedID)?.original + if (conn) { + setDrawerState(d => { + d.connection = conn + if (drawerState.selectedID === latestConntion.current.id) { + d.connection.completed = latestConntion.current.completed + } + }) + } else if (Object.keys(latestConntion.current).length !== 0 && !latestConntion.current.completed) { + setDrawerState(d => { d.connection.completed = true }) + } + }, [data, drawerState.selectedID, latestConntion, setDrawerState]) + return (
@@ -233,12 +239,12 @@ export default function Connections () {
{ devices.length > 1 && } - +
{ headerGroup.headers.map((column, idx) => { - const realColumn = column as unknown as TableColumn + const realColumn = column as unknown as TableColumn const id = realColumn.id return (
{ prepareRow(row) return ( -
+
handleConnectionSelected(row.original.id)}> { row.cells.map(cell => { const classname = classnames( @@ -293,6 +303,16 @@ export default function Connections () {
{t('closeAll.content')} + +
+ {t('info.title')} + setDrawerState('visible', false)} /> +
+ +
+ +
+
) } diff --git a/src/containers/Connections/store.ts b/src/containers/Connections/store.ts index 85a27d7..3e52fca 100644 --- a/src/containers/Connections/store.ts +++ b/src/containers/Connections/store.ts @@ -3,6 +3,25 @@ import { useState, useMemo, useRef, useCallback } from 'react' export type Connection = API.Connections & { completed?: boolean, uploadSpeed: number, downloadSpeed: number } +export 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 + original: Connection +} + class Store { protected connections = new Map() protected saveDisconnection = false diff --git a/src/containers/Connections/style.scss b/src/containers/Connections/style.scss index 3725d3e..af18282 100644 --- a/src/containers/Connections/style.scss +++ b/src/containers/Connections/style.scss @@ -63,7 +63,7 @@ .connections-header { position: sticky; top: 0; - z-index: 9999; + z-index: 999; white-space: nowrap; &:hover .connections-resizer { @@ -90,7 +90,7 @@ &.fixed { position: sticky; left: 0; - z-index: 999; + z-index: 998; background-color: $color-white; box-shadow: inset -9px 0 8px -14px $color-black; } diff --git a/src/containers/Settings/index.tsx b/src/containers/Settings/index.tsx index 955bb49..693245a 100644 --- a/src/containers/Settings/index.tsx +++ b/src/containers/Settings/index.tsx @@ -87,7 +87,7 @@ export default function Settings () { { label: t('values.global'), value: 'Global' }, { label: t('values.rules'), value: 'Rule' }, { label: t('values.direct'), value: 'Direct' }, - ] + ] as Array<{ label: string, value: string }> if (premium) { options.push({ label: t('values.script'), value: 'Script' }) } diff --git a/src/containers/Settings/style.scss b/src/containers/Settings/style.scss index a017e23..42c9401 100644 --- a/src/containers/Settings/style.scss +++ b/src/containers/Settings/style.scss @@ -16,7 +16,7 @@ line-height: 17px; &.modify-btn { - color: $color-primary-dark; + color: $color-primary; cursor: pointer; } } diff --git a/src/i18n/en_US.ts b/src/i18n/en_US.ts index fdcc375..c70e477 100644 --- a/src/i18n/en_US.ts +++ b/src/i18n/en_US.ts @@ -75,6 +75,25 @@ const EN = { download: 'Download', sourceIP: 'Source IP', }, + info: { + title: 'Connection', + id: 'ID', + host: 'Host', + hostEmpty: 'Empty', + dstIP: 'IP', + dstIPEmpty: 'Empty', + srcIP: 'Source', + upload: 'Upload', + download: 'Download', + network: 'Network', + inbound: 'Inbound', + rule: 'Rule', + chains: 'Chains', + status: 'Status', + opening: 'Open', + closed: 'Closed', + closeConnection: 'Close', + }, }, Proxies: { title: 'Proxies', @@ -105,6 +124,6 @@ const EN = { ok: 'Ok', cancel: 'Cancel', }, -} +} as const export default EN diff --git a/src/i18n/zh_CN.ts b/src/i18n/zh_CN.ts index 0d0b0d6..ec72a35 100644 --- a/src/i18n/zh_CN.ts +++ b/src/i18n/zh_CN.ts @@ -75,6 +75,25 @@ const CN = { download: '下载', sourceIP: '来源 IP', }, + info: { + title: '连接信息', + id: 'ID', + host: '域名', + hostEmpty: '空', + dstIP: 'IP', + dstIPEmpty: '空', + srcIP: '来源', + upload: '上传', + download: '下载', + network: '网络', + inbound: '入口', + rule: '规则', + chains: '代理', + status: '状态', + opening: '连接中', + closed: '已关闭', + closeConnection: '关闭连接', + }, }, Proxies: { title: '代理', @@ -105,6 +124,6 @@ const CN = { ok: '确 定', cancel: '取 消', }, -} +} as const export default CN diff --git a/src/lib/helper.ts b/src/lib/helper.ts index c3ccf06..4c208da 100644 --- a/src/lib/helper.ts +++ b/src/lib/helper.ts @@ -8,3 +8,14 @@ export function partition (arr: T[], fn: (arg: T) => boolean): [T[], T[]] { } return [left, right] } + +export 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]}` +} diff --git a/src/stores/jotai.ts b/src/stores/jotai.ts index eece51e..13f4281 100644 --- a/src/stores/jotai.ts +++ b/src/stores/jotai.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo } from 'react' import { get } from 'lodash-es' import useSWR from 'swr' import produce from 'immer' +import { Get } from 'type-fest' import { Language, locales, Lang, getDefaultLanguage } from '@i18n' import { useWarpImmerSetter, WritableDraft } from '@lib/jotai' @@ -14,10 +15,10 @@ import * as API from '@lib/request' import * as Models from '@models' import { partition } from '@lib/helper' import { isClashX, jsBridge } from '@lib/jsBridge' -import { useAPIInfo, useClient } from './request' import { StreamReader } from '@lib/streamer' import { Log } from '@models/Log' import { Snapshot } from '@lib/request' +import { useAPIInfo, useClient } from './request' export const identityAtom = atom(true) @@ -28,9 +29,9 @@ export function useI18n () { const lang = useMemo(() => defaultLang ?? getDefaultLanguage(), [defaultLang]) const translation = useCallback( - function (namespace: keyof typeof Language['en_US']) { - function t (path: string) { - return get(Language[lang][namespace], path) as string + function (namespace: Namespace) { + function t (path: Path): Get { + return get(Language[lang][namespace], path) } return { t } }, diff --git a/windi.config.ts b/windi.config.ts index 0056bf8..39700d7 100644 --- a/windi.config.ts +++ b/windi.config.ts @@ -8,7 +8,8 @@ export default defineConfig({ 500: '#57befc', 600: '#2c8af8' }, - red: '#f56c6c' + red: '#f56c6c', + green: '#67c23a' }, textShadow: { primary: '0 0 6px rgb(44 138 248 / 40%)'