diff options
| author | Olivi <[email protected]> | 2025-12-13 11:40:54 +0000 |
|---|---|---|
| committer | Olivi <[email protected]> | 2025-12-13 11:40:54 +0000 |
| commit | 4ac43e692cea4682179612e7fecb4c159e96c039 (patch) | |
| tree | 504322c2fbc1893ff8cab233075d1d629dd54962 /src | |
| parent | 3959e45747aabe9ec3dea011f66851d8c219a5fb (diff) | |
feat: add healthcheck functionality for specified proxy
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/proxies.ts | 16 | ||||
| -rw-r--r-- | src/components/proxies/Proxy.tsx | 44 | ||||
| -rw-r--r-- | src/components/proxies/ProxyLatency.module.scss | 48 | ||||
| -rw-r--r-- | src/components/proxies/ProxyLatency.tsx | 51 | ||||
| -rw-r--r-- | src/store/proxies.tsx | 95 | ||||
| -rw-r--r-- | src/store/types.ts | 6 |
6 files changed, 222 insertions, 38 deletions
diff --git a/src/api/proxies.ts b/src/api/proxies.ts index 0b98bd0..302014c 100644 --- a/src/api/proxies.ts +++ b/src/api/proxies.ts @@ -1,4 +1,5 @@ import { getURLAndInit } from '../misc/request-helper'; +import { ClashAPIConfig } from '../types'; const endpoint = '/proxies'; @@ -76,3 +77,18 @@ export async function healthcheckProviderByName(config, name) { options ); } + +export async function healthcheckProviderProxy( + config: ClashAPIConfig, + providerName: string, + proxyName: string +) { + const { url, init } = getURLAndInit(config); + const options = { ...init, method: 'GET' }; + return await fetch( + `${url}/providers/proxies/${encodeURIComponent(providerName)}/${encodeURIComponent( + proxyName + )}/healthcheck`, + options + ); +} diff --git a/src/components/proxies/Proxy.tsx b/src/components/proxies/Proxy.tsx index 833d94b..1ba966e 100644 --- a/src/components/proxies/Proxy.tsx +++ b/src/components/proxies/Proxy.tsx @@ -3,10 +3,11 @@ import cx from 'clsx'; import * as React from 'react'; import { keyCodes } from '~/misc/keycode'; -import { getLatencyTestUrl } from '~/store/app'; -import { ProxyItem } from '~/store/types'; +import { getClashAPIConfig, getLatencyTestUrl } from '~/store/app'; +import { DispatchFn, ProxyItem } from '~/store/types'; +import { ClashAPIConfig } from '~/types'; -import { getDelay, getProxies } from '../../store/proxies'; +import { getDelay, getProxies, healthcheckProxy } from '../../store/proxies'; import { connect } from '../StateProvider'; import s0 from './Proxy.module.scss'; import { ProxyLatency } from './ProxyLatency'; @@ -65,12 +66,12 @@ type ProxyProps = { name: string; now?: boolean; proxy: ProxyItem; - latency: any; + latency: { number?: number; error?: string; testing?: boolean }; httpsLatencyTest: boolean; isSelectable?: boolean; - udp: boolean; - tfo: boolean; onClick?: (proxyName: string) => unknown; + apiConfig: ClashAPIConfig; + dispatch: DispatchFn; }; function ProxySmallImpl({ @@ -86,7 +87,7 @@ function ProxySmallImpl({ const latencyNumber = latency?.number ?? delay; const color = useMemo( () => getProxyDotBackgroundColor({ number: latencyNumber }, httpsLatencyTest), - [latencyNumber] + [latencyNumber, httpsLatencyTest] ); const title = useMemo(() => { @@ -161,13 +162,22 @@ function ProxyImpl({ httpsLatencyTest, isSelectable, onClick, + apiConfig, + dispatch, }: ProxyProps) { const delay = proxy.history[proxy.history.length - 1]?.delay; - const latencyNumber = latency?.number ?? delay; + const latencyNumber = + typeof latency?.number === 'number' + ? latency.number + : typeof delay === 'number' + ? delay + : undefined; + const hasLatencyNumber = typeof latencyNumber === 'number' && latencyNumber > 0; const color = useMemo( - () => getLabelColor({ number: latencyNumber }, httpsLatencyTest), - [latencyNumber] + () => getLabelColor({ number: hasLatencyNumber ? latencyNumber : undefined }, httpsLatencyTest), + [hasLatencyNumber, latencyNumber, httpsLatencyTest] ); + const isTestingLatency = Boolean(latency?.testing); const doSelect = React.useCallback(() => { isSelectable && onClick && onClick(name); @@ -210,7 +220,10 @@ function ProxyImpl({ }); }, [isSelectable, now, latency]); - // const latencyNumber = latency?.number ?? proxy.history[proxy.history.length - 1]?.delay; + const runLatencyTest = React.useCallback(() => { + if (isTestingLatency) return; + dispatch(healthcheckProxy(apiConfig, name)); + }, [apiConfig, dispatch, isTestingLatency, name]); return ( <div @@ -241,7 +254,13 @@ function ProxyImpl({ {formatTfo(proxy.tfo)} </div> - {latencyNumber ? <ProxyLatency number={latencyNumber} color={color} /> : null} + <ProxyLatency + number={hasLatencyNumber ? latencyNumber : undefined} + color={color} + isTesting={isTestingLatency} + error={latency?.error} + onClick={runLatencyTest} + /> </div> </div> ); @@ -256,6 +275,7 @@ const mapState = (s: any, { name }) => { proxy: proxy, latency: delay[name], httpsLatencyTest: latencyTestUrl.startsWith('https://'), + apiConfig: getClashAPIConfig(s), }; }; diff --git a/src/components/proxies/ProxyLatency.module.scss b/src/components/proxies/ProxyLatency.module.scss index 37502a8..f1731f6 100644 --- a/src/components/proxies/ProxyLatency.module.scss +++ b/src/components/proxies/ProxyLatency.module.scss @@ -1,10 +1,54 @@ @import '~/styles/utils/custom-media'; .proxyLatency { - border-radius: 20px; - color: #eee; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 50px; + padding: 4px 10px; + gap: 4px; + border-radius: 9999px; + border: 1px solid var(--color-proxy-border); + background: var(--bg-near-transparent); + color: var(--color-text); font-size: 0.75em; + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, + color 0.15s ease, transform 0.15s ease; + user-select: none; + outline: none; @media (--breakpoint-not-small) { + padding: 5px 12px; font-size: 0.8em; } } + +.clickable { + cursor: pointer; +} + +.clickable:hover, +.clickable:focus-visible { + background: var(--color-bg-proxy); + border-color: var(--card-hover-border-lightness); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} + +.placeholder { + color: var(--color-text-secondary); +} + +.testing { + animation: proxyLatencyPulse 1s ease-in-out infinite; +} + +@keyframes proxyLatencyPulse { + 0% { + opacity: 0.8; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.8; + } +} diff --git a/src/components/proxies/ProxyLatency.tsx b/src/components/proxies/ProxyLatency.tsx index 48e55af..3f91eac 100644 --- a/src/components/proxies/ProxyLatency.tsx +++ b/src/components/proxies/ProxyLatency.tsx @@ -1,16 +1,59 @@ +import cx from 'clsx'; import * as React from 'react'; import s0 from './ProxyLatency.module.scss'; type ProxyLatencyProps = { - number: number; + number?: number; color: string; + isTesting?: boolean; + error?: string; + onClick?: () => void; }; -export function ProxyLatency({ number, color }: ProxyLatencyProps) { +export function ProxyLatency({ number, color, isTesting, error, onClick }: ProxyLatencyProps) { + const hasNumber = typeof number === 'number'; + const label = isTesting ? 'Testing...' : hasNumber ? `${number} ms` : error || '--'; + + const className = cx(s0.proxyLatency, { + [s0.clickable]: Boolean(onClick), + [s0.placeholder]: !hasNumber || Boolean(error), + [s0.testing]: isTesting, + }); + + const handleClick = React.useCallback( + (e: React.MouseEvent) => { + if (!onClick || isTesting) return; + e.preventDefault(); + e.stopPropagation(); + onClick(); + }, + [isTesting, onClick] + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (!onClick || isTesting) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + onClick(); + } + }, + [isTesting, onClick] + ); + return ( - <span className={s0.proxyLatency} style={{ color }}> - <span>{number} ms</span> + <span + className={className} + style={{ color: hasNumber ? color : undefined }} + role={onClick ? 'button' : undefined} + tabIndex={onClick ? 0 : undefined} + onClick={handleClick} + onKeyDown={handleKeyDown} + title={label} + > + <span>{label}</span> </span> ); } diff --git a/src/store/proxies.tsx b/src/store/proxies.tsx index ae783b9..9b803f9 100644 --- a/src/store/proxies.tsx +++ b/src/store/proxies.tsx @@ -138,6 +138,25 @@ export function healthcheckProviderByName(apiConfig: ClashAPIConfig, name: strin }; } +function updateDelayEntry( + dispatch: DispatchFn, + getState: GetStateFn, + name: string, + patch: { number?: number; error?: string; testing?: boolean; updatedAt?: number } +) { + const delayPrev = getDelay(getState()); + const prev = delayPrev[name] || {}; + dispatch('store/proxies#delay', (s: State) => { + s.proxies.delay = { + ...delayPrev, + [name]: { + ...prev, + ...patch, + }, + }; + }); +} + async function closeGroupConns( apiConfig: ClashAPIConfig, groupName: string, @@ -268,25 +287,28 @@ export function switchProxy(apiConfig: ClashAPIConfig, groupName: string, itemNa function requestDelayForProxyOnce(apiConfig: ClashAPIConfig, name: string) { return async (dispatch: DispatchFn, getState: GetStateFn) => { - const latencyTestUrl = getLatencyTestUrl(getState()); - const res = await proxiesAPI.requestDelayForProxy(apiConfig, name, latencyTestUrl); let error = ''; - if (res.ok === false) { - error = res.statusText; + let delayNumber: number | undefined; + try { + const latencyTestUrl = getLatencyTestUrl(getState()); + const res = await proxiesAPI.requestDelayForProxy(apiConfig, name, latencyTestUrl); + if (res.ok === false) { + error = res.statusText; + } + const body = await res.json(); + delayNumber = body?.delay; + } catch (err) { + error = (err as Error).message; } - const { delay } = await res.json(); - const delayPrev = getDelay(getState()); - const delayNext = { - ...delayPrev, - [name]: { - error, - number: delay, - }, - }; + const normalizedDelay = + typeof delayNumber === 'number' && delayNumber > 0 ? delayNumber : undefined; - dispatch('requestDelayForProxyOnce', (s) => { - s.proxies.delay = delayNext; + updateDelayEntry(dispatch, getState, name, { + error, + number: normalizedDelay, + testing: false, + updatedAt: Date.now(), }); }; } @@ -323,6 +345,41 @@ export function requestDelayAll(apiConfig: ClashAPIConfig) { }; } +export function healthcheckProxy(apiConfig: ClashAPIConfig, name: string) { + return async (dispatch: DispatchFn, getState: GetStateFn) => { + updateDelayEntry(dispatch, getState, name, { testing: true, error: '' }); + + let delayNumber: number | undefined; + let error = ''; + try { + const proxy = getProxies(getState())[name]; + const providerName = proxy?.providerName; + const latencyTestUrl = getLatencyTestUrl(getState()); + const res = providerName + ? await proxiesAPI.healthcheckProviderProxy(apiConfig, providerName, name) + : await proxiesAPI.requestDelayForProxy(apiConfig, name, latencyTestUrl); + if (res.ok === false) { + error = res.statusText; + } + const body = await res.json().catch(() => undefined); + delayNumber = body?.delay; + } catch (err) { + error = (err as Error).message || 'Request failed'; + } + + const normalizedDelay = + typeof delayNumber === 'number' && delayNumber > 0 ? delayNumber : undefined; + + const errorMessage = error || (normalizedDelay === undefined ? 'Timeout' : ''); + updateDelayEntry(dispatch, getState, name, { + number: normalizedDelay, + error: errorMessage, + testing: false, + updatedAt: Date.now(), + }); + }; +} + function retrieveGroupNamesFrom(proxies: Record<string, ProxyItem>) { let groupNames = []; let globalAll: string[]; @@ -370,13 +427,12 @@ function formatProxyProviders(providersInput: ProvidersRaw): { const names = []; for (let j = 0; j < proxiesArr.length; j++) { const proxy = proxiesArr[j]; - proxies[proxy.name] = proxy; + proxies[proxy.name] = { ...proxy, providerName: provider.name }; names.push(proxy.name); } - // mutate directly - provider.proxies = names; - providers.push(provider); + const formattedProvider = { ...provider, proxies: names }; + providers.push(formattedProvider); } return { @@ -389,6 +445,7 @@ export const actions = { requestDelayForProxies, closeModalClosePrevConns, closePrevConnsAndTheModal, + healthcheckProxy, }; export const proxyFilterText = atom({ diff --git a/src/store/types.ts b/src/store/types.ts index 3340c74..82be10d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -62,11 +62,15 @@ export type ProxyItem = { xudp?: boolean; tfo: boolean; history: LatencyHistory; + providerName?: string; all?: string[]; now?: string; }; export type ProxiesMapping = Record<string, ProxyItem>; -export type DelayMapping = Record<string, { number?: number }>; +export type DelayMapping = Record< + string, + { number?: number; error?: string; testing?: boolean; updatedAt?: number } +>; export type ProxyProvider = { name: string; |
