feat(tray): add a per-group latency test (#8173)

* feat(tray): add a per-group latency test

The tray's proxy group submenus offered no way to test a group: refreshing
the delays shown there meant opening the dashboard and running a test from
the proxy page.

Add a "Test Latency" entry at the head of each group submenu. It measures
the group through the proxy page's own view, so member resolution and
provider attribution match the page exactly: provider nodes are health
checked on their provider, anything else goes through the proxy delay
endpoint, and members the page cannot resolve are skipped. Nested groups
are therefore tested through their currently selected node, as the page
does. The menu is rebuilt when the run finishes.

The test URL follows the page's chain -- the group's own test URL, then the
configured default, then the built-in one -- including the page's validity
check, so a group with a malformed URL falls back where the page falls back.

A group test started from the app rebuilds the tray as well, so the two
views do not drift.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tray): keep latency tests and displayed results consistent

* fix(tray): isolate latency histories and preserve usable menus

* fix(tray): keep settings usable when proxy reads fail

* fix(tray): show selected node latency for proxy groups and display timeouts as T/O

Co-authored-by: wonfen <eiwonfen@gmail.com>

* fix(tray): refresh proxy latency on menu interaction

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: wonfen <eiwonfen@gmail.com>
This commit is contained in:
LokHsu
2026-10-09 09:11:07 +08:00
committed by GitHub
co-authored by Claude wonfen
parent d9dc18a57b
commit cb751052a4
37 changed files with 1251 additions and 225 deletions
+11 -6
View File
@@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
import { Box, IconButton, type SxProps, TextField } from '@mui/material'
import { Box, IconButton, type SxProps } from '@mui/material'
import { useDebounceFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
@@ -18,10 +18,11 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { isValidLatencyTestUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -69,7 +70,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
}, [groupName, testUrl, defaultLatencyUrl, url])
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
@@ -112,7 +116,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
)}
{textState === 'url' && (
<TextField
<ProxyTestUrlInput
name={groupName}
inputRef={inputRef}
autoComplete="new-password"
hiddenLabel
@@ -125,7 +130,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
e.preventDefault()
e.stopPropagation()
}}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
onCommit={(testUrl) => onHeadState({ testUrl })}
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
@@ -176,7 +181,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
+8
View File
@@ -23,7 +23,9 @@ import { useProfiles } from '@/hooks/use-profiles'
import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge'
import { useProxiesData, useSystemData } from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import {
isInteractableMember,
resolveMember,
@@ -133,6 +135,12 @@ function useProxyRenderState(
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally {
onProxies()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[ProxyGroups] Failed to sync tray latency:', error)
})
}
}),
)
+11 -6
View File
@@ -11,7 +11,7 @@ import {
SortByAlphaRounded,
SortRounded,
} from '@mui/icons-material'
import { Box, IconButton, TextField, type SxProps } from '@mui/material'
import { Box, IconButton, type SxProps } from '@mui/material'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,8 +20,9 @@ import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
import { isValidLatencyTestUrl } from '@/utils/network'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -72,7 +73,10 @@ export const ProxyHead = ({
'http://cp.cloudflare.com/generate_204'
useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
}, [groupName, testUrl, defaultLatencyUrl, url])
return (
@@ -97,7 +101,7 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
@@ -188,7 +192,8 @@ export const ProxyHead = ({
)}
{textState === 'url' && (
<TextField
<ProxyTestUrlInput
name={groupName}
autoComplete="new-password"
autoFocus={autoFocus}
hiddenLabel
@@ -197,7 +202,7 @@ export const ProxyHead = ({
size="small"
variant="outlined"
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
onCommit={(testUrl) => onHeadState({ testUrl })}
sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
@@ -0,0 +1,55 @@
import { TextField, type TextFieldProps } from '@mui/material'
import { useEffect, useEffectEvent, useRef, useState } from 'react'
import { useProfiles } from '@/hooks/use-profiles'
type Props = Omit<TextFieldProps, 'value' | 'onChange'> & {
value: string
onCommit: (url: string) => void
}
export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => {
const { profiles } = useProfiles()
return (
<DraftTestUrlInput
key={JSON.stringify([profiles?.current, name])}
name={name}
value={value}
{...props}
/>
)
}
const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => {
const [draft, setDraft] = useState<string | null>(null)
const pendingRef = useRef<string | null>(null)
const commitOnUnmount = useEffectEvent(() => {
if (pendingRef.current !== null && pendingRef.current !== value)
onCommit(pendingRef.current)
})
useEffect(() => () => commitOnUnmount(), [])
return (
<TextField
{...props}
value={draft ?? value}
onChange={(event) => {
pendingRef.current = event.target.value
setDraft(event.target.value)
}}
onBlur={() => {
const next = pendingRef.current
pendingRef.current = null
setDraft(null)
if (next !== null && next !== value) onCommit(next)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
const input = event.target as HTMLInputElement
input.blur()
}
}}
/>
)
}
+13 -4
View File
@@ -13,11 +13,18 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) =>
kind: 'node',
ref: { kind: 'node', name, recordId: `${memberIndex}` },
node: {
recordId: `${memberIndex}`,
history: [{ delay }],
extra: {
'http://cp.cloudflare.com/generate_204': {
alive: true,
history: [{ time: new Date(0).toISOString(), delay }],
},
},
source: { kind: 'provider', providerName: `${memberIndex}` },
},
},
}) as ResolvedMemberOccurrence
}) as unknown as ResolvedMemberOccurrence
afterEach(() => vi.restoreAllMocks())
@@ -36,7 +43,9 @@ test('matches the previous comparator for cached, fallback and sentinel delays',
for (const cached of [false, true]) {
const group = `sort-${cached}`
if (cached) {
values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay))
values.forEach((delay, i) =>
delayManager.setDelay(list[i].member, group, delay),
)
}
for (const timeout of [10000, 20, 0, NaN]) {
const expected = list
@@ -66,7 +75,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
node(3, 25),
]
const cachedOrder = [list[0], list[2], list[1], list[3]]
delayManager.setDelay('0', 'expiry', 1)
delayManager.setDelay(list[0].member, 'expiry', 1)
const get = vi.spyOn(delayManager, 'getDelayFix')
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
expect(get).toHaveBeenCalledTimes(list.length)
@@ -80,7 +89,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
list[3],
list[0],
])
delayManager.setDelay('0', 'expiry', 2)
delayManager.setDelay(list[0].member, 'expiry', 2)
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
get.mockClear()
filterSort([], 'expiry', '', 1)
+60 -18
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useReducer } from 'react'
import { useProfiles } from '@/hooks/use-profiles'
import {
profileTestUrls,
commitProfileTestUrl,
} from '@/services/proxy-test-url'
import { ProxySortType } from './use-filter-sort'
@@ -32,22 +36,45 @@ export const DEFAULT_STATE: HeadState = {
}
type HeadStateAction =
| { type: 'reset' }
| { type: 'replace'; payload: Record<string, HeadState> }
| { type: 'reset'; profile: string }
| { type: 'replace'; profile: string; payload: Record<string, HeadState> }
| { type: 'update'; groupName: string; patch: Partial<HeadState> }
| { type: 'urls'; urls: Record<string, string> }
interface LoadedHeadState {
profile: string | null
heads: Record<string, HeadState>
}
function headStateReducer(
state: Record<string, HeadState>,
state: LoadedHeadState,
action: HeadStateAction,
): Record<string, HeadState> {
): LoadedHeadState {
switch (action.type) {
case 'reset':
return {}
return { profile: action.profile, heads: {} }
case 'replace':
return action.payload
return { profile: action.profile, heads: action.payload }
case 'urls': {
let heads = state.heads
for (const [name, testUrl] of Object.entries(action.urls)) {
if (heads[name]?.testUrl === testUrl) continue
heads = {
...heads,
[name]: { ...DEFAULT_STATE, ...heads[name], testUrl },
}
}
return heads === state.heads ? state : { ...state, heads }
}
case 'update': {
const prev = state[action.groupName] || DEFAULT_STATE
return { ...state, [action.groupName]: { ...prev, ...action.patch } }
const prev = state.heads[action.groupName] || DEFAULT_STATE
return {
...state,
heads: {
...state.heads,
[action.groupName]: { ...prev, ...action.patch },
},
}
}
default:
return state
@@ -55,30 +82,42 @@ function headStateReducer(
}
export function useHeadStateNew() {
const { profiles } = useProfiles()
const { profiles, current: profile } = useProfiles()
const current = profiles?.current || ''
const [state, dispatch] = useReducer(headStateReducer, {})
const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer(
headStateReducer,
{ profile: null, heads: {} },
)
useEffect(() => {
const urls = profile ? profileTestUrls(profile) : {}
if (loadedProfile === current) {
dispatch({ type: 'urls', urls })
return
}
try {
const data = JSON.parse(
localStorage.getItem(HEAD_STATE_KEY)!,
localStorage.getItem(HEAD_STATE_KEY) || '{}',
) as HeadStateStorage
const value = data[current] || {}
const value = data?.[current] || {}
for (const [name, testUrl] of Object.entries(urls)) {
value[name] = { ...DEFAULT_STATE, ...value[name], testUrl }
}
if (value && typeof value === 'object') {
dispatch({ type: 'replace', payload: value })
dispatch({ type: 'replace', profile: current, payload: value })
} else {
dispatch({ type: 'reset' })
dispatch({ type: 'reset', profile: current })
}
} catch {
dispatch({ type: 'reset' })
dispatch({ type: 'reset', profile: current })
}
}, [current])
}, [current, loadedProfile, profile])
useEffect(() => {
if (loadedProfile !== current) return
const timer = setTimeout(() => {
try {
const item = localStorage.getItem(HEAD_STATE_KEY)
@@ -94,13 +133,16 @@ export function useHeadStateNew() {
})
return () => clearTimeout(timer)
}, [state, current])
}, [state, current, loadedProfile])
const setHeadState = useCallback(
(groupName: string, obj: Partial<HeadState>) => {
dispatch({ type: 'update', groupName, patch: obj })
if (current && obj.testUrl !== undefined) {
void commitProfileTestUrl(current, groupName, obj.testUrl)
}
},
[],
[current],
)
return [state, setHeadState] as const