diff options
| author | Larvan2 <[email protected]> | 2026-03-15 15:01:57 +0800 |
|---|---|---|
| committer | Larvan2 <[email protected]> | 2026-03-15 15:01:57 +0800 |
| commit | 0e420859f5f7011ba124c965d8319bf3bf4c5fe3 (patch) | |
| tree | 2fc344b757e119ebae6e0b6243121fddba61603c /src/modules | |
| parent | 17c4d2855ffb6914fcbece27367bafdd27a4c182 (diff) | |
refactor: reorganize code
Diffstat (limited to 'src/modules')
| -rw-r--r-- | src/modules/about/hooks.ts | 8 | ||||
| -rw-r--r-- | src/modules/about/utils.ts | 30 | ||||
| -rw-r--r-- | src/modules/backend/hooks.ts | 114 | ||||
| -rw-r--r-- | src/modules/backend/utils.ts | 64 | ||||
| -rw-r--r-- | src/modules/config/hooks.ts | 184 | ||||
| -rw-r--r-- | src/modules/config/utils.ts | 54 | ||||
| -rw-r--r-- | src/modules/connections/hooks.ts | 200 | ||||
| -rw-r--r-- | src/modules/connections/utils.ts | 220 | ||||
| -rw-r--r-- | src/modules/home/hooks.ts | 36 | ||||
| -rw-r--r-- | src/modules/home/utils.ts | 5 | ||||
| -rw-r--r-- | src/modules/logs/hooks.ts | 68 | ||||
| -rw-r--r-- | src/modules/logs/utils.ts | 9 | ||||
| -rw-r--r-- | src/modules/proxies/hooks.ts | 282 | ||||
| -rw-r--r-- | src/modules/proxies/utils.ts | 58 | ||||
| -rw-r--r-- | src/modules/rules/hooks.ts | 108 | ||||
| -rw-r--r-- | src/modules/rules/utils.ts | 24 |
16 files changed, 1464 insertions, 0 deletions
diff --git a/src/modules/about/hooks.ts b/src/modules/about/hooks.ts new file mode 100644 index 0000000..925f2c4 --- /dev/null +++ b/src/modules/about/hooks.ts @@ -0,0 +1,8 @@ +import { useQuery } from 'react-query'; + +import { fetchVersion } from '~/api/version'; +import { ClashAPIConfig } from '~/types'; + +export function useAboutVersionQuery(apiConfig: ClashAPIConfig) { + return useQuery(['/version', apiConfig], () => fetchVersion('/version', apiConfig)); +} diff --git a/src/modules/about/utils.ts b/src/modules/about/utils.ts new file mode 100644 index 0000000..b9d5af2 --- /dev/null +++ b/src/modules/about/utils.ts @@ -0,0 +1,30 @@ +type VersionData = { + version?: string; + premium?: boolean; + meta?: boolean; +}; + +export function getCoreVersionMeta(version?: VersionData) { + if (!version?.version) { + return null; + } + + if (version.meta && version.premium) { + return { + name: 'sing-box', + link: 'https://github.com/SagerNet/sing-box', + }; + } + + if (version.meta) { + return { + name: 'Clash.Meta', + link: 'https://github.com/MetaCubeX/Clash.Meta', + }; + } + + return { + name: 'Clash', + link: 'https://github.com/Dreamacro/clash', + }; +} diff --git a/src/modules/backend/hooks.ts b/src/modules/backend/hooks.ts new file mode 100644 index 0000000..04e0c56 --- /dev/null +++ b/src/modules/backend/hooks.ts @@ -0,0 +1,114 @@ +import * as React from 'react'; + +import { fetchConfigs } from '~/store/configs'; +import { closeModal } from '~/store/modals'; +import type { DispatchFn } from '~/store/types'; +import type { ClashAPIConfig } from '~/types'; + +import { detectEmbeddedAPIBaseURL, normalizeAPIBaseURL, verifyAPIConfig } from './utils'; + +const { useCallback, useEffect, useState } = React; + +export function useBackendConfigForm({ + onAddConfig, +}: { + onAddConfig: (config: ClashAPIConfig) => void; +}) { + const [baseURL, setBaseURL] = useState(''); + const [secret, setSecret] = useState(''); + const [errMsg, setErrMsg] = useState(''); + + const handleInputOnChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + setErrMsg(''); + const target = e.target; + const { name, value } = target; + + switch (name) { + case 'baseURL': + setBaseURL(value); + break; + case 'secret': + setSecret(value); + break; + default: + throw new Error(`unknown input name ${name}`); + } + }, []); + + const onConfirm = useCallback(() => { + const normalizedResult = normalizeAPIBaseURL(baseURL, window.location.protocol); + if ('error' in normalizedResult) { + setErrMsg(normalizedResult.error); + return; + } + + const nextConfig = { baseURL: normalizedResult.baseURL, secret }; + verifyAPIConfig(nextConfig).then(([status, message]) => { + if (status !== 0) { + setErrMsg(message ?? 'Failed to connect'); + return; + } + + onAddConfig(nextConfig); + }); + }, [baseURL, onAddConfig, secret]); + + const handleContentOnKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLInputElement>) => { + if ( + e.target instanceof Element && + (!e.target.tagName || e.target.tagName.toUpperCase() !== 'INPUT') + ) { + return; + } + + if (e.key !== 'Enter') return; + + onConfirm(); + }, + [onConfirm] + ); + + useEffect(() => { + let isCancelled = false; + + detectEmbeddedAPIBaseURL().then((detectedBaseURL) => { + if (!isCancelled && detectedBaseURL) { + setBaseURL(detectedBaseURL); + } + }); + + return () => { + isCancelled = true; + }; + }, []); + + return { + baseURL, + secret, + errMsg, + handleInputOnChange, + handleContentOnKeyDown, + onConfirm, + }; +} + +export function useBackendDiscovery({ + apiConfig, + dispatch, +}: { + apiConfig: ClashAPIConfig; + dispatch: DispatchFn; +}) { + const closeAPIConfigModal = useCallback(() => { + dispatch(closeModal('apiConfig')); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchConfigs(apiConfig)); + }, [apiConfig, dispatch]); + + return { + closeAPIConfigModal, + }; +} diff --git a/src/modules/backend/utils.ts b/src/modules/backend/utils.ts new file mode 100644 index 0000000..47d2bd1 --- /dev/null +++ b/src/modules/backend/utils.ts @@ -0,0 +1,64 @@ +import { fetchConfigs } from '~/api/configs'; +import type { ClashAPIConfig } from '~/types'; + +export const DEFAULT_API_BASE_URL = 'http://127.0.0.1:9090'; + +const Ok = 0; + +export function normalizeAPIBaseURL(baseURL: string, currentProtocol: string) { + let normalizedBaseURL = baseURL || DEFAULT_API_BASE_URL; + + if (normalizedBaseURL) { + const prefix = normalizedBaseURL.substring(0, 7); + if (prefix.includes(':/')) { + if (prefix !== 'http://' && prefix !== 'https:/') { + return { error: 'Must starts with http:// or https://' }; + } + } else if (currentProtocol) { + normalizedBaseURL = `${currentProtocol}//${normalizedBaseURL}`; + } + } + + return { baseURL: normalizedBaseURL }; +} + +export async function verifyAPIConfig(apiConfig: ClashAPIConfig): Promise<[number, string?]> { + try { + new URL(apiConfig.baseURL); + } catch (e) { + if (apiConfig.baseURL) { + const prefix = apiConfig.baseURL.substring(0, 7); + if (prefix !== 'http://' && prefix !== 'https:/') { + return [1, 'Must starts with http:// or https://']; + } + } + + return [1, 'Invalid URL']; + } + + try { + const res = await fetchConfigs(apiConfig); + if (res.status > 399) { + return [1, res.statusText]; + } + return [Ok]; + } catch (e) { + return [1, 'Failed to connect']; + } +} + +export async function detectEmbeddedAPIBaseURL() { + try { + const res = await fetch('/'); + if (res.headers.get('content-type')?.includes('application/json')) { + const data = await res.json(); + if (data.hello === 'clash') { + return window.location.origin; + } + } + } catch (e) { + // ignore auto detection failures + } + + return null; +} diff --git a/src/modules/config/hooks.ts b/src/modules/config/hooks.ts new file mode 100644 index 0000000..b55e6e0 --- /dev/null +++ b/src/modules/config/hooks.ts @@ -0,0 +1,184 @@ +import * as React from 'react'; +import { useQuery } from 'react-query'; + +import * as logsApi from '~/api/logs'; +import { fetchVersion } from '~/api/version'; +import { + fetchConfigs, + flushFakeIPPool, + reloadConfigFile, + restartCore, + updateConfigs, + upgradeCore, + upgradeGeo, + upgradeUI, +} from '~/store/configs'; +import { openModal } from '~/store/modals'; +import { ClashGeneralConfig, DispatchFn } from '~/store/types'; +import { ClashAPIConfig } from '~/types'; + +const { useCallback, useEffect, useRef, useState } = React; + +type UpdateAppConfigFn = (name: string, value: unknown) => void; + +function useConfigVersionQuery(apiConfig: ClashAPIConfig) { + return useQuery(['/version', apiConfig], () => fetchVersion('/version', apiConfig)); +} + +export function useConfigState(configs: ClashGeneralConfig) { + const [configState, setConfigStateInternal] = useState(configs); + const refConfigs = useRef(configs); + + useEffect(() => { + if (refConfigs.current !== configs) { + setConfigStateInternal(configs); + } + refConfigs.current = configs; + }, [configs]); + + const setConfigState = useCallback((name: string, value: any) => { + setConfigStateInternal((prev) => ({ ...prev, [name]: value })); + }, []); + + const setTunConfigState = useCallback((name: string, value: any) => { + setConfigStateInternal((prev) => ({ + ...prev, + tun: { ...prev.tun, [name]: value }, + })); + }, []); + + return { + configState, + setConfigState, + setTunConfigState, + }; +} + +export function useConfigPage({ + apiConfig, + configs, + dispatch, + updateAppConfig, +}: { + apiConfig: ClashAPIConfig; + configs: ClashGeneralConfig; + dispatch: DispatchFn; + updateAppConfig: UpdateAppConfigFn; +}) { + useEffect(() => { + dispatch(fetchConfigs(apiConfig)); + }, [apiConfig, dispatch]); + + const { configState, setConfigState, setTunConfigState } = useConfigState(configs); + const versionQuery = useConfigVersionQuery(apiConfig); + + const openAPIConfigModal = useCallback(() => { + dispatch(openModal('apiConfig')); + }, [dispatch]); + + const handleInputOnChange = useCallback( + ({ name, value }: { name: string; value: any }) => { + switch (name) { + case 'mode': + case 'log-level': + case 'allow-lan': + case 'sniffing': + setConfigState(name, value); + dispatch(updateConfigs(apiConfig, { [name]: value })); + if (name === 'log-level') { + logsApi.reconnect({ ...apiConfig, logLevel: value }); + } + break; + case 'mitm-port': + case 'redir-port': + case 'socks-port': + case 'mixed-port': + case 'port': + if (value !== '') { + const num = parseInt(value, 10); + if (num < 0 || num > 65535) return; + } + setConfigState(name, value); + break; + case 'enable': + case 'stack': + setTunConfigState(name, value); + dispatch(updateConfigs(apiConfig, { tun: { [name]: value } })); + break; + default: + return; + } + }, + [apiConfig, dispatch, setConfigState, setTunConfigState] + ); + + const handleInputOnBlur = useCallback( + ( + e: + | React.FocusEvent<HTMLSelectElement | HTMLInputElement> + | React.ChangeEvent<HTMLSelectElement | HTMLInputElement> + ) => { + const { name, value } = e.target; + + switch (name) { + case 'port': + case 'socks-port': + case 'mixed-port': + case 'redir-port': + case 'mitm-port': { + const num = parseInt(value, 10); + if (num < 0 || num > 65535) return; + dispatch(updateConfigs(apiConfig, { [name]: num })); + break; + } + case 'latencyTestUrl': + updateAppConfig(name, value); + break; + case 'device name': + case 'interface name': + break; + default: + throw new Error(`unknown input name ${name}`); + } + }, + [apiConfig, dispatch, updateAppConfig] + ); + + const handleReloadConfigFile = useCallback(() => { + dispatch(reloadConfigFile(apiConfig)); + }, [apiConfig, dispatch]); + + const handleRestartCore = useCallback(() => { + dispatch(restartCore(apiConfig)); + }, [apiConfig, dispatch]); + + const handleUpgradeCore = useCallback(() => { + dispatch(upgradeCore(apiConfig)); + }, [apiConfig, dispatch]); + + const handleUpgradeGeo = useCallback(() => { + dispatch(upgradeGeo(apiConfig)); + }, [apiConfig, dispatch]); + + const handleUpgradeUI = useCallback(() => { + dispatch(upgradeUI(apiConfig)); + }, [apiConfig, dispatch]); + + const handleFlushFakeIPPool = useCallback(() => { + dispatch(flushFakeIPPool(apiConfig)); + }, [apiConfig, dispatch]); + + return { + configState, + openAPIConfigModal, + handleInputOnChange, + handleInputOnBlur, + handleReloadConfigFile, + handleRestartCore, + handleUpgradeCore, + handleUpgradeGeo, + handleUpgradeUI, + handleFlushFakeIPPool, + versionQuery, + }; +} diff --git a/src/modules/config/utils.ts b/src/modules/config/utils.ts new file mode 100644 index 0000000..8300311 --- /dev/null +++ b/src/modules/config/utils.ts @@ -0,0 +1,54 @@ +export type SelectOption = [string, string]; +export type PortField = { + key: string; + label: string; +}; + +export const CONFIG_CHART_STYLE_PROPS = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }]; + +export const LOG_LEVEL_OPTIONS: SelectOption[] = [ + ['debug', 'Debug'], + ['info', 'Info'], + ['warning', 'Warning'], + ['error', 'Error'], + ['silent', 'Silent'], +]; + +export const PORT_FIELDS: PortField[] = [ + { key: 'port', label: 'Http Port' }, + { key: 'socks-port', label: 'Socks5 Port' }, + { key: 'mixed-port', label: 'Mixed Port' }, + { key: 'redir-port', label: 'Redir Port' }, + { key: 'mitm-port', label: 'MITM Port' }, +]; + +export const LANGUAGE_OPTIONS: SelectOption[] = [ + ['zh-cn', '简体中文'], + ['zh-tw', '繁體中文'], + ['en', 'English'], + ['vi', 'Vietnamese'], + ['ru', 'Русский'], +]; + +export const MODE_OPTIONS: SelectOption[] = [ + ['direct', 'Direct'], + ['rule', 'Rule'], + ['script', 'Script'], + ['global', 'Global'], +]; + +export const TUN_STACK_OPTIONS: SelectOption[] = [ + ['gvisor', 'gVisor'], + ['mixed', 'Mixed'], + ['system', 'System'], +]; + +export function getBackendContent(version: { meta?: boolean; premium?: boolean } | undefined) { + if (version?.meta && !version?.premium) { + return 'Clash.Meta '; + } + if (version?.meta && version?.premium) { + return 'sing-box '; + } + return 'Clash Premium'; +} diff --git a/src/modules/connections/hooks.ts b/src/modules/connections/hooks.ts new file mode 100644 index 0000000..1f1b415 --- /dev/null +++ b/src/modules/connections/hooks.ts @@ -0,0 +1,200 @@ +import * as React from 'react'; +import { useRecoilState } from 'recoil'; + +import { ConnectionItem } from '~/api/connections'; +import * as connAPI from '~/api/connections'; +import { + closedConnectionsState, + connectionsState, + FormattedConn, + isRefreshPausedState, + MAX_CLOSED_CONNECTIONS, +} from '~/store/connections'; +import { ClashAPIConfig } from '~/types'; + +import { + ALL_SOURCE_IP, + arrayToIdKv, + CONNECTION_COLUMNS_DEFAULT, + ConnectionColumn, + filterConns, + formatConnectionDataItem, + getInitialColumns, + getInitialHiddenColumns, + getInitialSourceMap, + getNameFromSource, + HIDDEN_COLUMNS_DEFAULT, + saveColumns, + saveHiddenColumns, + saveSourceMap, + SourceMapItem, +} from './utils'; + +const { useCallback, useEffect, useMemo, useRef, useState } = React; + +export function useSourceMapState() { + const [sourceMapModal, setSourceMapModal] = useState(false); + const [sourceMap, setSourceMap] = useState<SourceMapItem[]>(() => getInitialSourceMap()); + + const openModalSource = useCallback(() => { + setSourceMap((prev) => (prev.length === 0 ? [{ reg: '', name: '' }] : prev)); + setSourceMapModal(true); + }, []); + + const closeModalSource = useCallback(() => { + setSourceMap((prev) => { + const nextSourceMap = prev.filter((item) => item.reg || item.name); + saveSourceMap(nextSourceMap); + return nextSourceMap; + }); + setSourceMapModal(false); + }, []); + + return { + sourceMap, + setSourceMap, + sourceMapModal, + openModalSource, + closeModalSource, + }; +} + +export function useConnectionsStream(apiConfig: ClashAPIConfig, sourceMap: SourceMapItem[]) { + const [conns, setConns] = useRecoilState(connectionsState); + const [closedConns, setClosedConns] = useRecoilState(closedConnectionsState); + const [isRefreshPaused, setIsRefreshPaused] = useRecoilState(isRefreshPausedState); + const [reConnectCount, setReConnectCount] = useState(0); + const prevConnsRef = useRef<FormattedConn[]>(conns); + + const toggleIsRefreshPaused = useCallback(() => { + setIsRefreshPaused((value) => !value); + }, [setIsRefreshPaused]); + + const closeAllConnections = useCallback(() => { + connAPI.closeAllConnections(apiConfig); + }, [apiConfig]); + + const read = useCallback( + ({ connections }: { connections: ConnectionItem[] }) => { + const prevConnsKv = arrayToIdKv(prevConnsRef.current); + const now = Date.now(); + const nextConnections = + connections?.map((item: ConnectionItem) => + formatConnectionDataItem(item, prevConnsKv, now, sourceMap) + ) ?? []; + const closed: FormattedConn[] = []; + + for (const connection of prevConnsRef.current) { + const idx = nextConnections.findIndex((conn) => conn.id === connection.id); + if (idx < 0) closed.push(connection); + } + + if (closed.length > 0) { + setClosedConns((prev) => [...closed, ...prev].slice(0, MAX_CLOSED_CONNECTIONS + 1)); + } + + if ( + nextConnections && + (nextConnections.length !== 0 || prevConnsRef.current.length !== 0) && + !isRefreshPaused + ) { + prevConnsRef.current = nextConnections; + setConns(nextConnections); + } else { + prevConnsRef.current = nextConnections; + } + }, + [isRefreshPaused, setClosedConns, setConns, sourceMap] + ); + + useEffect(() => { + return connAPI.fetchData(apiConfig, read, () => { + setTimeout(() => { + setReConnectCount((prev) => prev + 1); + }, 1000); + }); + }, [apiConfig, read, reConnectCount]); + + return { + conns, + closedConns, + isRefreshPaused, + toggleIsRefreshPaused, + closeAllConnections, + }; +} + +export function useConnectionColumns() { + const [hiddenColumns, setHiddenColumnsState] = useState<string[]>(() => + getInitialHiddenColumns() + ); + const [columns, setColumnsState] = useState<ConnectionColumn[]>(() => getInitialColumns()); + + const setHiddenColumns = useCallback((nextHiddenColumns: string[]) => { + setHiddenColumnsState(nextHiddenColumns); + saveHiddenColumns(nextHiddenColumns); + }, []); + + const setColumns = useCallback((nextColumns: ConnectionColumn[]) => { + setColumnsState(nextColumns); + saveColumns(nextColumns); + }, []); + + const resetColumns = useCallback(() => { + setHiddenColumnsState([...HIDDEN_COLUMNS_DEFAULT]); + setColumnsState([...CONNECTION_COLUMNS_DEFAULT]); + saveHiddenColumns([...HIDDEN_COLUMNS_DEFAULT]); + saveColumns([...CONNECTION_COLUMNS_DEFAULT]); + }, []); + + return { + hiddenColumns, + columns, + setHiddenColumns, + setColumns, + resetColumns, + }; +} + +export function useConnectionFilters({ + conns, + closedConns, + sourceMap, + t, +}: { + conns: FormattedConn[]; + closedConns: FormattedConn[]; + sourceMap: SourceMapItem[]; + t: (key: string) => string; +}) { + const [filterKeyword, setFilterKeyword] = useState(''); + const [filterSourceIpStr, setFilterSourceIpStr] = useState(ALL_SOURCE_IP); + + const filteredConns = useMemo( + () => filterConns(conns, filterKeyword, filterSourceIpStr), + [conns, filterKeyword, filterSourceIpStr] + ); + const filteredClosedConns = useMemo( + () => filterConns(closedConns, filterKeyword, filterSourceIpStr), + [closedConns, filterKeyword, filterSourceIpStr] + ); + + const connIpSet = useMemo(() => { + return [ + [ALL_SOURCE_IP, t('All')], + ...Array.from(new Set(conns.map((x) => x.sourceIP))) + .sort() + .map((value) => [value, getNameFromSource(value, sourceMap).trim() || t('internel')]), + ]; + }, [conns, sourceMap, t]); + + return { + filterKeyword, + setFilterKeyword, + filterSourceIpStr, + setFilterSourceIpStr, + filteredConns, + filteredClosedConns, + connIpSet, + }; +} diff --git a/src/modules/connections/utils.ts b/src/modules/connections/utils.ts new file mode 100644 index 0000000..47110c8 --- /dev/null +++ b/src/modules/connections/utils.ts @@ -0,0 +1,220 @@ +import { ConnectionItem } from '~/api/connections'; +import { FormattedConn } from '~/store/connections'; + +export type SourceMapItem = { + reg: string; + name: string; +}; + +export type ConnectionColumn = { + accessor: string; + Header?: string; + show?: boolean; + sortDescFirst?: boolean; +}; + +export const ALL_SOURCE_IP = 'ALL_SOURCE_IP'; +export const SOURCE_MAP_STORAGE_KEY = 'sourceMap'; +export const CONNECTIONS_PADDING_BOTTOM = 30; +export const HIDDEN_COLUMNS_STORAGE_KEY = 'hiddenColumns'; +export const COLUMNS_STORAGE_KEY = 'columns'; + +const sortDescFirst = true; + +export const HIDDEN_COLUMNS_DEFAULT = ['id']; +export const CONNECTION_COLUMNS_DEFAULT: ConnectionColumn[] = [ + { accessor: 'id', show: false }, + { Header: 'c_type', accessor: 'type' }, + { Header: 'c_process', accessor: 'process' }, + { Header: 'c_host', accessor: 'host' }, + { Header: 'c_rule', accessor: 'rule' }, + { Header: 'c_chains', accessor: 'chains' }, + { Header: 'c_time', accessor: 'start' }, + { Header: 'c_dl_speed', accessor: 'downloadSpeedCurr', sortDescFirst }, + { Header: 'c_ul_speed', accessor: 'uploadSpeedCurr', sortDescFirst }, + { Header: 'c_dl', accessor: 'download', sortDescFirst }, + { Header: 'c_ul', accessor: 'upload', sortDescFirst }, + { Header: 'c_source', accessor: 'source' }, + { Header: 'c_destination_ip', accessor: 'destinationIP' }, + { Header: 'c_sni', accessor: 'sniffHost' }, + { Header: 'c_ctrl', accessor: 'ctrl' }, +]; + +export function getInitialSourceMap(): SourceMapItem[] { + const sourceMap = localStorage.getItem(SOURCE_MAP_STORAGE_KEY); + return sourceMap ? JSON.parse(sourceMap) : []; +} + +export function saveSourceMap(sourceMap: SourceMapItem[]) { + localStorage.setItem(SOURCE_MAP_STORAGE_KEY, JSON.stringify(sourceMap)); +} + +export function getInitialHiddenColumns(): string[] { + const hiddenColumns = localStorage.getItem(HIDDEN_COLUMNS_STORAGE_KEY); + return hiddenColumns ? JSON.parse(hiddenColumns) : [...HIDDEN_COLUMNS_DEFAULT]; +} + +export function saveHiddenColumns(hiddenColumns: string[]) { + localStorage.setItem(HIDDEN_COLUMNS_STORAGE_KEY, JSON.stringify(hiddenColumns)); +} + +export function getInitialColumns(): ConnectionColumn[] { + const savedColumns = localStorage.getItem(COLUMNS_STORAGE_KEY); + const columnOrder: ConnectionColumn[] | null = savedColumns ? JSON.parse(savedColumns) : null; + + if (!columnOrder) { + return [...CONNECTION_COLUMNS_DEFAULT]; + } + + return [...CONNECTION_COLUMNS_DEFAULT].sort((prev, next) => { + const prevIdx = columnOrder.findIndex((column) => column.accessor === prev.accessor); + const nextIdx = columnOrder.findIndex((column) => column.accessor === next.accessor); + + if (prevIdx === -1) { + return 1; + } + + if (nextIdx === -1) { + return -1; + } + + return prevIdx - nextIdx; + }); +} + +export function saveColumns(columns: ConnectionColumn[]) { + localStorage.setItem(COLUMNS_STORAGE_KEY, JSON.stringify(columns)); +} + +export function arrayToIdKv<T extends { id: string }>(items: T[]) { + const result: Record<string, T> = {}; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + result[item.id] = item; + } + return result; +} + +function hasSubstring(value: string, pattern: string) { + return value.toLowerCase().includes(pattern.toLowerCase()); +} + +function filterConnIps(conns: FormattedConn[], ipStr: string) { + return conns.filter((each) => each.sourceIP === ipStr); +} + +export function filterConns(conns: FormattedConn[], keyword: string, sourceIp: string) { + let result = conns; + if (keyword !== '') { + result = conns.filter((conn) => + [ + conn.host, + conn.sourceIP, + conn.sourcePort, + conn.destinationIP, + conn.chains, + conn.rule, + conn.type, + conn.network, + conn.process, + ].some((field) => hasSubstring(field, keyword)) + ); + } + if (sourceIp !== ALL_SOURCE_IP) { + result = filterConnIps(result, sourceIp); + } + + return result; +} + +export function getNameFromSource( + source: string, + sourceMap: SourceMapItem[], + defaultVal?: string +): string { + let sourceName = defaultVal ?? source; + + sourceMap.forEach(({ reg, name }) => { + if (!reg) return; + + if (reg.startsWith('/')) { + const regExp = new RegExp(reg.replace('/', ''), 'g'); + + if (regExp.test(source) && name) { + sourceName = `${name}(${source})`; + } + } else if (source === reg && name) { + sourceName = `${name}(${source})`; + } + }); + + return sourceName; +} + +export function modifyChains(chains: string[]): string { + if (!Array.isArray(chains) || chains.length === 0) { + return ''; + } + + if (chains.length === 1) { + return chains[0]; + } + + return `${chains[chains.length - 1]} -> ${chains[0]}`; +} + +export function formatConnectionDataItem( + item: ConnectionItem, + prevKv: Record<string, FormattedConn>, + now: number, + sourceMap: SourceMapItem[] +): FormattedConn { + const { id, upload, download, start, chains, rule, rulePayload, metadata } = item; + const prev = prevKv[id]; + + if (prev) { + return { + ...prev, + upload, + download, + start: now - prev.startTime, + downloadSpeedCurr: download - prev.download, + uploadSpeedCurr: upload - prev.upload, + }; + } + + const { + host, + destinationPort, + destinationIP, + remoteDestination, + network, + type, + sourceIP, + sourcePort, + process, + sniffHost, + } = metadata; + const host2 = host || destinationIP; + const source = `${sourceIP}:${sourcePort}`; + const startTime = new Date(start).valueOf(); + + return { + id, + upload, + download, + start: now - startTime, + startTime, + chains: modifyChains(chains), + rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`, + ...metadata, + host: `${host2}:${destinationPort}`, + sniffHost: sniffHost || '-', + type: `${type}(${network})`, + source: getNameFromSource(sourceIP, sourceMap, source), + downloadSpeedCurr: 0, + uploadSpeedCurr: 0, + process: process || '-', + destinationIP: remoteDestination || destinationIP || host, + }; +}
\ No newline at end of file diff --git a/src/modules/home/hooks.ts b/src/modules/home/hooks.ts new file mode 100644 index 0000000..5c22bc6 --- /dev/null +++ b/src/modules/home/hooks.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import * as connAPI from '~/api/connections'; +import prettyBytes from '~/misc/pretty-bytes'; +import { ClashAPIConfig } from '~/types'; + +const { useCallback, useEffect, useState } = React; + +export function useConnectionSummary(apiConfig: ClashAPIConfig) { + const [state, setState] = useState({ + upTotal: '0 B', + dlTotal: '0 B', + connNumber: 0, + mUsage: '0 B', + }); + + const read = useCallback( + ({ downloadTotal, uploadTotal, connections, memory }) => { + setState({ + upTotal: prettyBytes(uploadTotal), + dlTotal: prettyBytes(downloadTotal), + connNumber: connections ? connections.length : 0, + mUsage: prettyBytes(memory), + }); + }, + [setState] + ); + + useEffect(() => { + return connAPI.fetchData(apiConfig, read, () => { + /* noop */ + }); + }, [apiConfig, read]); + + return state; +} diff --git a/src/modules/home/utils.ts b/src/modules/home/utils.ts new file mode 100644 index 0000000..177cfc9 --- /dev/null +++ b/src/modules/home/utils.ts @@ -0,0 +1,5 @@ +import prettyBytes from '~/misc/pretty-bytes'; + +export function formatTrafficRate(value: number) { + return `${prettyBytes(value || 0)}/s`; +} diff --git a/src/modules/logs/hooks.ts b/src/modules/logs/hooks.ts new file mode 100644 index 0000000..45e09e4 --- /dev/null +++ b/src/modules/logs/hooks.ts @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import { fetchLogs, reconnect as reconnectLogs, stop as stopLogs } from '~/api/logs'; +import { appendLog } from '~/store/logs'; +import { DispatchFn, Log } from '~/store/types'; +import { ClashAPIConfig } from '~/types'; + +import { LOGS_SCROLL_BOTTOM_THRESHOLD } from './utils'; + +const { useCallback, useEffect, useRef, useState } = React; + +type UpdateAppConfigFn = (name: string, value: unknown) => void; + +export function useLogsPage({ + dispatch, + logLevel, + apiConfig, + logs, + logStreamingPaused, + updateAppConfig, +}: { + dispatch: DispatchFn; + logLevel: string; + apiConfig: ClashAPIConfig; + logs: Log[]; + logStreamingPaused: boolean; + updateAppConfig: UpdateAppConfigFn; +}) { + const toggleIsRefreshPaused = useCallback(() => { + logStreamingPaused ? reconnectLogs({ ...apiConfig, logLevel }) : stopLogs(); + updateAppConfig('logStreamingPaused', !logStreamingPaused); + }, [apiConfig, logLevel, logStreamingPaused, updateAppConfig]); + + const appendLogInternal = useCallback((log) => dispatch(appendLog(log)), [dispatch]); + + useEffect(() => { + fetchLogs({ ...apiConfig, logLevel }, appendLogInternal); + }, [apiConfig, logLevel, appendLogInternal]); + + const scrollRef = useRef<HTMLDivElement>(null); + const [isAtBottom, setIsAtBottom] = useState(true); + + const scrollToBottom = useCallback(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, []); + + useEffect(() => { + if (isAtBottom) { + scrollToBottom(); + } + }, [logs, isAtBottom, scrollToBottom]); + + const onScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const atBottom = scrollHeight - scrollTop - clientHeight < LOGS_SCROLL_BOTTOM_THRESHOLD; + setIsAtBottom(atBottom); + }, []); + + return { + toggleIsRefreshPaused, + scrollRef, + isAtBottom, + scrollToBottom, + onScroll, + }; +}
\ No newline at end of file diff --git a/src/modules/logs/utils.ts b/src/modules/logs/utils.ts new file mode 100644 index 0000000..cb0d7c6 --- /dev/null +++ b/src/modules/logs/utils.ts @@ -0,0 +1,9 @@ +export const LOG_TYPES: Record<string, string> = { + debug: 'debug', + info: 'info', + warning: 'warn', + error: 'error', +}; + +export const LOGS_HEIGHT_RATIO = 0.8; +export const LOGS_SCROLL_BOTTOM_THRESHOLD = 50;
\ No newline at end of file diff --git a/src/modules/proxies/hooks.ts b/src/modules/proxies/hooks.ts new file mode 100644 index 0000000..330f64e --- /dev/null +++ b/src/modules/proxies/hooks.ts @@ -0,0 +1,282 @@ +import * as React from 'react'; +import { useRecoilState } from 'recoil'; + +import { + fetchProxies, + NonProxyTypes, + proxyFilterText, + requestDelayAll, + updateProviderByName, + updateProviders, +} from '~/store/proxies'; +import { + DelayMapping, + DispatchFn, + FormattedProxyProvider, + ProxiesMapping, + ProxyItem, +} from '~/store/types'; +import { ClashAPIConfig } from '~/types'; + +import { splitItemsByLayout } from './utils'; + +const { useCallback, useEffect, useMemo, useRef, useState } = React; + +function filterAvailableProxies(list: string[], delay: DelayMapping) { + return list.filter((name) => { + const d = delay[name]; + if (d === undefined) { + return true; + } + if (d.number === 0) { + return false; + } + return true; + }); +} + +const getSortDelay = ( + d: + | undefined + | { + number?: number; + }, + proxyInfo: ProxyItem +) => { + if (d && typeof d.number === 'number' && d.number > 0) { + return d.number; + } + + const type = proxyInfo && proxyInfo.type; + if (type && NonProxyTypes.indexOf(type) > -1) return -1; + + return 999999; +}; + +const ProxySortingFns = { + Natural: (proxies: string[]) => proxies, + LatencyAsc: (proxies: string[], delay: DelayMapping, proxyMapping?: ProxiesMapping) => { + return proxies.sort((a, b) => { + const d1 = getSortDelay(delay[a], proxyMapping && proxyMapping[a]); + const d2 = getSortDelay(delay[b], proxyMapping && proxyMapping[b]); + return d1 - d2; + }); + }, + LatencyDesc: (proxies: string[], delay: DelayMapping, proxyMapping?: ProxiesMapping) => { + return proxies.sort((a, b) => { + const d1 = getSortDelay(delay[a], proxyMapping && proxyMapping[a]); + const d2 = getSortDelay(delay[b], proxyMapping && proxyMapping[b]); + return d2 - d1; + }); + }, + NameAsc: (proxies: string[]) => { + return proxies.sort(); + }, + NameDesc: (proxies: string[]) => { + return proxies.sort((a, b) => { + if (a > b) return -1; + if (a < b) return 1; + return 0; + }); + }, +}; + +function filterStrArr(all: string[], searchText: string) { + const segments = searchText + .toLowerCase() + .split(' ') + .map((x) => x.trim()) + .filter((x) => !!x); + + if (segments.length === 0) return all; + + return all.filter((name) => { + let i = 0; + for (; i < segments.length; i++) { + const seg = segments[i]; + if (name.toLowerCase().indexOf(seg) > -1) return true; + } + return false; + }); +} + +function filterAvailableProxiesAndSort( + all: string[], + delay: DelayMapping, + hideUnavailableProxies: boolean, + filterText: string, + proxySortBy: string, + proxies?: ProxiesMapping +) { + let filtered = [...all]; + if (hideUnavailableProxies) { + filtered = filterAvailableProxies(all, delay); + } + + if (typeof filterText === 'string' && filterText !== '') { + filtered = filterStrArr(filtered, filterText); + } + return ProxySortingFns[proxySortBy](filtered, delay, proxies); +} + +export function useFilteredAndSorted( + all: string[], + delay: DelayMapping, + hideUnavailableProxies: boolean, + proxySortBy: string, + proxies?: ProxiesMapping +) { + const [filterText] = useRecoilState(proxyFilterText); + return useMemo( + () => + filterAvailableProxiesAndSort( + all, + delay, + hideUnavailableProxies, + filterText, + proxySortBy, + proxies + ), + [all, delay, hideUnavailableProxies, filterText, proxySortBy, proxies] + ); +} + +export function useUpdateProviderItem({ + dispatch, + apiConfig, + name, +}: { + dispatch: DispatchFn; + apiConfig: ClashAPIConfig; + name: string; +}) { + return useCallback( + () => dispatch(updateProviderByName(apiConfig, name)), + [apiConfig, dispatch, name] + ); +} + +export function useUpdateProviderItems({ + dispatch, + apiConfig, + names, +}: { + dispatch: DispatchFn; + apiConfig: ClashAPIConfig; + names: string[]; +}): [() => unknown, boolean] { + const [isLoading, setIsLoading] = useState(false); + + const action = useCallback(async () => { + if (isLoading) { + return; + } + + setIsLoading(true); + try { + await dispatch(updateProviders(apiConfig, names)); + } catch (e) { + // ignore + } + setIsLoading(false); + }, [apiConfig, dispatch, names, isLoading]); + + return [action, isLoading]; +} + +export function useTestLatencyAction({ + dispatch, + apiConfig, +}: { + dispatch: DispatchFn; + apiConfig: ClashAPIConfig; +}): [() => unknown, boolean] { + const [isTestingLatency, setIsTestingLatency] = useState(false); + const requestDelayAllFn = useCallback(() => { + if (isTestingLatency) return; + + setIsTestingLatency(true); + dispatch(requestDelayAll(apiConfig)).then( + () => setIsTestingLatency(false), + () => setIsTestingLatency(false) + ); + }, [apiConfig, dispatch, isTestingLatency]); + return [requestDelayAllFn, isTestingLatency]; +} + +export function useProxiesPage({ + dispatch, + apiConfig, + groupNames, + proxyProviders, + proxiesLayout, +}: { + dispatch: DispatchFn; + apiConfig: ClashAPIConfig; + groupNames: string[]; + proxyProviders: FormattedProxyProvider[]; + proxiesLayout: string; +}) { + const refFetchedTimestamp = useRef<{ startAt?: number; completeAt?: number }>({}); + + const fetchProxiesHooked = useCallback(() => { + refFetchedTimestamp.current.startAt = Date.now(); + dispatch(fetchProxies(apiConfig)).then(() => { + refFetchedTimestamp.current.completeAt = Date.now(); + }); + }, [apiConfig, dispatch]); + + useEffect(() => { + fetchProxiesHooked(); + + const fn = () => { + if ( + refFetchedTimestamp.current.startAt && + Date.now() - refFetchedTimestamp.current.startAt > 3e4 + ) { + fetchProxiesHooked(); + } + }; + window.addEventListener('focus', fn, false); + return () => window.removeEventListener('focus', fn, false); + }, [fetchProxiesHooked]); + + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + const closeSettingsModal = useCallback(() => { + setIsSettingsModalOpen(false); + }, []); + const openSettingsModal = useCallback(() => { + setIsSettingsModalOpen(true); + }, []); + + const [activeTab, setActiveTab] = useState('proxies'); + const handleTabKeyDown = useCallback( + (tab: string) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + setActiveTab(tab); + } + }, + [] + ); + + const proxyGroups = useMemo(() => { + const formatted = groupNames.map((name, i) => ({ name, i })); + return splitItemsByLayout(formatted, proxiesLayout); + }, [groupNames, proxiesLayout]); + + const providers = useMemo(() => { + const formatted = proxyProviders.map((item, i) => ({ item, i })); + return splitItemsByLayout(formatted, proxiesLayout); + }, [proxyProviders, proxiesLayout]); + + return { + isSettingsModalOpen, + openSettingsModal, + closeSettingsModal, + activeTab, + setActiveTab, + handleTabKeyDown, + proxyGroups, + providers, + }; +} diff --git a/src/modules/proxies/utils.ts b/src/modules/proxies/utils.ts new file mode 100644 index 0000000..59e24a0 --- /dev/null +++ b/src/modules/proxies/utils.ts @@ -0,0 +1,58 @@ +import { DelayMapping, ProxiesMapping } from '~/store/types'; + +export const PROXY_SORT_OPTIONS = [ + ['Natural', 'order_natural'], + ['LatencyAsc', 'order_latency_asc'], + ['LatencyDesc', 'order_latency_desc'], + ['NameAsc', 'order_name_asc'], + ['NameDesc', 'order_name_desc'], +] as const; + +export function formatQty(qty: number) { + return qty < 100 ? String(qty) : '99+'; +} + +export function splitItemsByLayout<T>(items: T[], layout: string) { + if (layout !== 'double') { + return [items]; + } + + const left: T[] = []; + const right: T[] = []; + items.forEach((item, index) => { + if (index % 2 === 0) { + left.push(item); + } else { + right.push(item); + } + }); + + return [left, right]; +} + +export function getProxyLatency( + proxies: ProxiesMapping, + delay: DelayMapping, + name: string, + visited = new Set<string>() +) { + if (visited.has(name)) return undefined; + visited.add(name); + + const latency = delay[name]; + if (latency && (latency.testing || typeof latency.number === 'number' || latency.error)) { + return latency; + } + + const proxy = proxies[name]; + if (proxy && proxy.now && proxies[proxy.now]) { + return getProxyLatency(proxies, delay, proxy.now, visited); + } + + const delayFromHistory = proxy?.history?.[proxy.history.length - 1]?.delay; + if (typeof delayFromHistory === 'number' && delayFromHistory > 0) { + return { number: delayFromHistory }; + } + + return latency; +} diff --git a/src/modules/rules/hooks.ts b/src/modules/rules/hooks.ts new file mode 100644 index 0000000..e99b978 --- /dev/null +++ b/src/modules/rules/hooks.ts @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useRecoilState } from 'recoil'; + +import { + fetchRuleProviders, + refreshRuleProviderByName, + updateRuleProviders, +} from '~/api/rule-provider'; +import { fetchRules } from '~/api/rules'; +import { ruleFilterText } from '~/store/rules'; +import type { ClashAPIConfig } from '~/types'; + +const { useCallback, useState } = React; + +export function useUpdateRuleProviderItem( + name: string, + apiConfig: ClashAPIConfig +): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] { + const queryClient = useQueryClient(); + const { mutate, isLoading } = useMutation(refreshRuleProviderByName, { + onSuccess: () => { + queryClient.invalidateQueries('/providers/rules'); + }, + }); + const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => { + ev.preventDefault(); + mutate({ name, apiConfig }); + }; + return [onClickRefreshButton, isLoading]; +} + +export function useUpdateAllRuleProviderItems( + apiConfig: ClashAPIConfig +): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] { + const queryClient = useQueryClient(); + const { data: provider } = useRuleProviderQuery(apiConfig); + const { mutate, isLoading } = useMutation(updateRuleProviders, { + onSuccess: () => { + queryClient.invalidateQueries('/providers/rules'); + }, + }); + const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => { + ev.preventDefault(); + mutate({ names: provider.names, apiConfig }); + }; + return [onClickRefreshButton, isLoading]; +} + +export function useInvalidateQueries() { + const queryClient = useQueryClient(); + return useCallback(() => { + queryClient.invalidateQueries('/rules'); + queryClient.invalidateQueries('/providers/rules'); + }, [queryClient]); +} + +export function useRuleProviderQuery(apiConfig: ClashAPIConfig) { + return useQuery(['/providers/rules', apiConfig], () => + fetchRuleProviders('/providers/rules', apiConfig) + ); +} + +export function useRuleAndProvider(apiConfig: ClashAPIConfig) { + const { data: rules, isFetching } = useQuery(['/rules', apiConfig], () => + fetchRules('/rules', apiConfig) + ); + const { data: provider } = useRuleProviderQuery(apiConfig); + + const [filterText] = useRecoilState(ruleFilterText); + if (filterText === '') { + return { rules, provider, isFetching }; + } + + const f = filterText.toLowerCase(); + return { + rules: rules.filter((r) => r.payload.toLowerCase().indexOf(f) >= 0), + isFetching, + provider: { + byName: provider.byName, + names: provider.names.filter((t) => t.toLowerCase().indexOf(f) >= 0), + }, + }; +} + +export function useRulesPage(apiConfig: ClashAPIConfig) { + const { rules, provider } = useRuleAndProvider(apiConfig); + const [activeTab, setActiveTab] = useState('rules'); + const isRulesTab = activeTab === 'rules'; + + const handleTabKeyDown = useCallback( + (tab: string) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + setActiveTab(tab); + } + }, + [] + ); + + return { + rules, + provider, + activeTab, + setActiveTab, + isRulesTab, + handleTabKeyDown, + }; +}
\ No newline at end of file diff --git a/src/modules/rules/utils.ts b/src/modules/rules/utils.ts new file mode 100644 index 0000000..c1d1464 --- /dev/null +++ b/src/modules/rules/utils.ts @@ -0,0 +1,24 @@ +import { ClashAPIConfig } from '~/types'; + +export type RulesListItemData = { + rules: any[] | null; + provider: any; + apiConfig: ClashAPIConfig; +}; + +export function itemKey(index: number, { rules, provider }: RulesListItemData) { + if (!rules) { + return provider.names[index]; + } + return rules[index].id; +} + +export function getItemSizeFactory({ isRulesTab }: { isRulesTab: boolean }) { + return function getItemSize() { + return isRulesTab ? 70 : 100; + }; +} + +export function formatQty(qty: number) { + return qty < 100 ? String(qty) : '99+'; +} |
