Files
clash-verge-rev/src/components/proxy/proxy-groups.tsx
T
cb751052a4 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>
2026-10-09 09:11:07 +08:00

648 lines
18 KiB
TypeScript

import { DragDropProvider } from '@dnd-kit/react'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import { useLockFn } from 'ahooks'
import { throttle } from 'lodash-es'
import {
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
BaseEmpty,
BaseLoading,
StickyVirtualList,
type StickyVirtualListHandle,
} from '@/components/base'
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,
type ProxyGroupView,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { ProxyEmptyState } from './proxy-empty-state'
import {
resolveEmptyListReason,
resolveProxyListState,
} from './proxy-empty-state-model'
import {
DEFAULT_HOVER_DELAY,
ProxyGroupNavigator,
} from './proxy-group-navigator'
import { ProxyRender } from './proxy-render'
import {
PROXY_GROUP_HEADER_SENSORS,
useProxyGroupHeaderLayout,
} from './use-proxy-group-header-layout'
import {
hasRenderableItems,
type IRenderItem,
useRenderList,
} from './use-render-list'
const ProxyGroupsChain = lazy(() =>
import('./proxy-groups-chain').then((m) => ({
default: m.ProxyGroupsChain,
})),
)
function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
const ref = useRef(fn)
ref.current = fn
return useCallback((...args: Parameters<T>) => ref.current(...args), []) as T
}
interface Props {
mode: string
isChainMode?: boolean
chainConfigData?: string | null
}
function useEmptyRenderList() {
const { isProxyViewError } = useProxiesData()
const { runningMode } = useSystemData()
return (
<ProxyEmptyState
reason={resolveEmptyListReason({ runningMode, isProxyViewError })}
/>
)
}
function useProxyRenderState(
mode: string,
isChainMode: boolean,
activeSelectedGroup: string | null,
) {
const { verge } = useVerge()
const { proxyView } = useProxiesData()
const { renderList, onProxies, onHeadState } = useRenderList(
mode,
isChainMode,
activeSelectedGroup,
)
const scrollPositionKey = useMemo(
() =>
isChainMode
? `${mode}:chain:${activeSelectedGroup ?? 'all'}`
: `${mode}:normal`,
[activeSelectedGroup, isChainMode, mode],
)
const timeout = verge?.default_latency_timeout || 10000
const handleCheckAll = useStableCallback(
useLockFn(async (groupName: string) => {
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`)
const group =
proxyView?.groups.find(({ name }) => name === groupName) ??
(proxyView?.global?.name === groupName ? proxyView.global : undefined)
const occurrences =
proxyView && group
? group.members.map((member, memberIndex) => ({
memberIndex,
member: resolveMember(proxyView, member),
}))
: []
const interactable = occurrences
.map(({ member }) => member)
.filter(isInteractableMember)
debugLog(`[ProxyGroups] 找到代理数量: ${interactable.length}`)
const url = delayManager.getUrl(groupName)
debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`)
try {
await delayManager.checkListDelay(interactable, groupName, timeout)
debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`)
} catch (error) {
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)
})
}
}),
)
const saveScrollPosition = useCallback(
(scrollTop: number) => {
const scrollPositions = localStorage.getItem('proxy-scroll-positions')
? JSON.parse(localStorage.getItem('proxy-scroll-positions') ?? '{}')
: {}
scrollPositions[scrollPositionKey] = scrollTop
try {
localStorage.setItem(
'proxy-scroll-positions',
JSON.stringify(scrollPositions),
)
} catch (e) {
console.error('Error saving scroll position:', e)
}
},
[scrollPositionKey],
)
const getScrollPosition = useCallback(() => {
try {
const savedPositions = localStorage.getItem('proxy-scroll-positions')
if (savedPositions) {
const positions = JSON.parse(savedPositions)
const savedPosition = positions[scrollPositionKey]
return savedPosition ?? 0
}
} catch (e) {
console.error('Error restoring scroll position:', e)
}
}, [scrollPositionKey])
return {
verge,
renderList,
onProxies,
onHeadState,
handleCheckAll,
saveScrollPosition,
getScrollPosition,
}
}
function ChainProxyGroups(props: {
mode: string
chainConfigData?: string | null
}) {
const { mode, chainConfigData } = props
const { proxyView } = useProxiesData()
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
const availableGroups = useMemo(() => {
const groups = proxyView?.groups
if (!groups) return []
return groups.filter(
(group) => group.type === 'Selector' || group.type === 'URLTest',
)
}, [proxyView?.groups])
const defaultRuleGroup = useMemo(() => {
if (mode === 'rule' && availableGroups.length > 0) {
return availableGroups[0].name
}
return null
}, [availableGroups, mode])
const activeSelectedGroup = selectedGroup ?? defaultRuleGroup
const {
renderList,
onHeadState,
handleCheckAll,
getScrollPosition,
saveScrollPosition,
} = useProxyRenderState(mode, true, activeSelectedGroup)
const emptyList = useEmptyRenderList()
const parentRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef(0)
const showScrollTopRef = useRef(false)
const activeStickyIndexRef = useRef<number | null>(null)
const [showScrollTop, setShowScrollTop] = useState(false)
const stickyGroupIndexes = useMemo(
() =>
renderList.flatMap((item, index) =>
item.type === 0 && !item.group.hidden ? [index] : [],
),
[renderList],
)
const rangeExtractor = useCallback(
(range: Parameters<typeof defaultRangeExtractor>[0]) => {
const activeStickyIndex = [...stickyGroupIndexes]
.reverse()
.find((index) => index <= range.startIndex)
activeStickyIndexRef.current = activeStickyIndex ?? null
const indexes = defaultRangeExtractor(range)
return activeStickyIndex == null || indexes.includes(activeStickyIndex)
? indexes
: [activeStickyIndex, ...indexes]
},
[stickyGroupIndexes],
)
const virtualizer = useVirtualizer({
count: renderList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 15,
getItemKey: (index) => renderList[index]?.key ?? index,
rangeExtractor,
})
const virtualItems = virtualizer.getVirtualItems()
const activeStickyIndex = activeStickyIndexRef.current
useLayoutEffect(() => {
if (renderList.length === 0) return
const node = parentRef.current
if (!node) return
const savedPosition = getScrollPosition()
if (savedPosition !== undefined) {
node.scrollTop = savedPosition
scrollTopRef.current = savedPosition
const nextShowScrollTop = savedPosition > 100
showScrollTopRef.current = nextShowScrollTop
queueMicrotask(() => setShowScrollTop(nextShowScrollTop))
}
}, [renderList.length, getScrollPosition])
const saveScrollPositionThrottled = useMemo(
() => throttle(saveScrollPosition, 500),
[saveScrollPosition],
)
const handleScroll = useCallback(
(event: Event) => {
const target = event.target as HTMLElement | null
const nextScrollTop = target?.scrollTop ?? 0
const nextShowScrollTop = nextScrollTop > 100
scrollTopRef.current = nextScrollTop
if (showScrollTopRef.current !== nextShowScrollTop) {
showScrollTopRef.current = nextShowScrollTop
setShowScrollTop(nextShowScrollTop)
}
saveScrollPositionThrottled(nextScrollTop)
},
[saveScrollPositionThrottled],
)
useEffect(() => {
const node = parentRef.current
if (!node) return
const listener = handleScroll as EventListener
const options: AddEventListenerOptions = { passive: true }
node.addEventListener('scroll', listener, options)
return () => {
saveScrollPosition(scrollTopRef.current)
node.removeEventListener('scroll', listener, options)
}
}, [handleScroll, saveScrollPosition])
const scrollToTop = useCallback(() => {
parentRef.current?.scrollTo?.({
top: 0,
behavior: 'smooth',
})
scrollTopRef.current = 0
}, [])
const handleLocation = useStableCallback((group: ProxyGroupView) => {
if (!group) return
const { name, now } = group
const index = renderList.findIndex(
(item) =>
item.group?.name === name &&
((item.type === 2 && item.member?.member.ref.name === now) ||
(item.type === 4 &&
item.memberCol?.some(({ member }) => member.ref.name === now))),
)
if (index >= 0) {
virtualizer.scrollToIndex(index, {
align: 'center',
behavior: 'smooth',
})
}
})
if (!hasRenderableItems(renderList)) return emptyList
return (
<Suspense fallback={<BaseLoading />}>
<ProxyGroupsChain
mode={mode}
chainConfigData={chainConfigData}
availableGroups={availableGroups}
activeSelectedGroup={activeSelectedGroup}
showScrollTop={showScrollTop}
parentRef={parentRef}
totalSize={virtualizer.getTotalSize()}
virtualItems={virtualItems}
renderList={renderList}
activeStickyIndex={activeStickyIndex}
measureElement={virtualizer.measureElement}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onLocation={handleLocation}
onGroupSelect={setSelectedGroup}
onScrollToTop={scrollToTop}
/>
</Suspense>
)
}
function NormalProxyGroups(props: { mode: string }) {
const { mode } = props
const stickyListRef = useRef<StickyVirtualListHandle>(null)
const {
verge,
renderList,
onProxies,
onHeadState,
handleCheckAll,
getScrollPosition,
saveScrollPosition,
} = useProxyRenderState(mode, false, null)
const emptyList = useEmptyRenderList()
const { onDragEnd: onHeaderDragEnd } = useProxyGroupHeaderLayout()
const renderFirstRef = useRef(true)
// Do not persist intermediate positions produced while restoring virtual scroll.
const isRestoringRef = useRef(false)
useLayoutEffect(() => {
if (renderList.length === 0) return
if (!renderFirstRef.current) return
const node = stickyListRef.current?.getScrollElement()
if (!node) return
const savedPosition = getScrollPosition()
if (!savedPosition) {
renderFirstRef.current = false
return
}
// Retry across frames until virtual-list measurements can reach the saved offset.
isRestoringRef.current = true
let rafId = 0
let attempts = 0
const maxAttempts = 30
const step = () => {
const el = stickyListRef.current?.getScrollElement()
if (!el) {
isRestoringRef.current = false
return
}
el.scrollTop = savedPosition
attempts += 1
const reached = Math.abs(el.scrollTop - savedPosition) <= 1
if (reached || attempts >= maxAttempts) {
renderFirstRef.current = false
isRestoringRef.current = false
return
}
rafId = requestAnimationFrame(step)
}
rafId = requestAnimationFrame(step)
return () => {
cancelAnimationFrame(rafId)
isRestoringRef.current = false
}
}, [renderList.length, getScrollPosition])
const saveScrollPositionThrottled = useMemo(
() => throttle(saveScrollPosition, 500),
[saveScrollPosition],
)
const handleScroll = useCallback(
(event: Event) => {
if (isRestoringRef.current) return
const target = event.target as HTMLElement | null
const nextScrollTop = target?.scrollTop ?? 0
saveScrollPositionThrottled(nextScrollTop)
},
[saveScrollPositionThrottled],
)
useEffect(() => {
const node = stickyListRef.current?.getScrollElement()
if (!node) return
const listener = handleScroll as EventListener
const options: AddEventListenerOptions = { passive: true }
node.addEventListener('scroll', listener, options)
return () => {
node.removeEventListener('scroll', listener, options)
}
}, [handleScroll])
const { handleProxyGroupChange } = useProxySelection({
onSuccess: () => {
onProxies()
},
onError: (error) => {
console.error('代理切换失败', error)
onProxies()
},
})
const handleChangeProxy = useCallback(
(group: ProxyGroupView, member: ResolvedProxyMember) => {
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
if (!isInteractableMember(member)) return
handleProxyGroupChange(group, { name: member.ref.name })
},
[handleProxyGroupChange],
)
const handleLocation = useStableCallback((group: ProxyGroupView) => {
if (!group) return
const { name, now } = group
const index = renderList.findIndex(
(e) =>
e.group?.name === name &&
((e.type === 2 && e.member?.member.ref.name === now) ||
(e.type === 4 &&
e.memberCol?.some(({ member }) => member.ref.name === now))),
)
if (index >= 0) {
stickyListRef.current?.scrollToIndex(index, {
align: 'center',
behavior: 'smooth',
})
}
})
const handleGroupLocationByName = useCallback(
(groupName: string) => {
const index = renderList.findIndex(
(item) => item.type === 0 && item.group?.name === groupName,
)
if (index >= 0) {
stickyListRef.current?.scrollToIndex(index, {
align: 'start',
behavior: 'smooth',
})
}
},
[renderList],
)
const proxyGroupNames = useMemo(() => {
const names = renderList
.filter((item) => item.type === 0 && item.group?.name)
.map((item) => item.group!.name)
return Array.from(new Set(names))
}, [renderList])
const handleGroupToggle = useCallback(
async (group: ProxyGroupView) => {
const index = renderList.findIndex(
(item) => item.type === 0 && item.group.name === group.name,
)
if (index < 0) return
if (!stickyListRef.current?.isItemScrolledPastStart(index, 1)) return
stickyListRef.current.scrollToIndex(index, {
align: 'start',
behavior: 'auto',
})
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
},
[renderList],
)
const renderGroupItem = useCallback(
(item: IRenderItem, _index: number, stickyed: boolean) => (
<ProxyRender
item={item}
stickyed={stickyed}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={async (groupName, patch) => {
if (stickyed && patch.filterText !== undefined) {
handleGroupLocationByName(groupName)
await stickyListRef.current?.waitForScrollEnd()
}
onHeadState(groupName, patch)
}}
onChangeProxy={handleChangeProxy}
onGroupToggle={handleGroupToggle}
/>
),
[
handleChangeProxy,
handleCheckAll,
onHeadState,
handleLocation,
handleGroupToggle,
handleGroupLocationByName,
],
)
const renderProxyItem = useCallback(
(item: IRenderItem) => (
<ProxyRender
key={item.key}
item={item}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
),
[handleChangeProxy, handleCheckAll, onHeadState, handleLocation],
)
if (!hasRenderableItems(renderList)) return emptyList
return (
<div style={{ position: 'relative', height: '100%' }}>
<DragDropProvider
sensors={PROXY_GROUP_HEADER_SENSORS}
onDragEnd={onHeaderDragEnd}
>
<StickyVirtualList
ref={stickyListRef}
items={renderList}
isGroupItem={(item) => item.type === 0}
getItemKey={(item) => item.key}
estimateGroupItemHeight={76}
estimateItemHeight={64}
renderGroupItem={renderGroupItem}
renderItem={renderProxyItem}
/>
</DragDropProvider>
{mode === 'rule' && (
<ProxyGroupNavigator
proxyGroupNames={proxyGroupNames}
onGroupLocation={handleGroupLocationByName}
enableHoverJump={verge?.enable_hover_jump_navigator ?? true}
hoverDelay={verge?.hover_jump_navigator_delay ?? DEFAULT_HOVER_DELAY}
/>
)}
</div>
)
}
export const ProxyGroups = (props: Props) => {
const { mode, isChainMode = false, chainConfigData } = props
const { profiles, isLoading: isProfilesLoading } = useProfiles()
const { isProxyViewPending } = useProxiesData()
const { isRunningModePending } = useSystemData()
const listState = resolveProxyListState({
mode,
profiles,
isProfilesPending: !profiles && isProfilesLoading,
isProxyViewPending,
isRunningModePending,
})
switch (listState.kind) {
case 'direct':
return <BaseEmpty textKey="proxies.page.messages.directMode" />
case 'loading':
return <BaseLoading />
case 'empty':
return <ProxyEmptyState reason={listState.reason} />
case 'render':
return isChainMode ? (
<ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
) : (
<NormalProxyGroups mode={mode} />
)
}
}