Files
clash-verge-rev/src/services/delay.ts
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

463 lines
13 KiB
TypeScript

import {
delayProxyByName,
healthcheckNodeInProvider,
type ProxyDelay,
} from 'tauri-plugin-mihomo-api'
import {
memberDetails,
providerNameOf,
type InteractableProxyMember,
type ResolvedProxyMember,
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
import { isValidLatencyTestUrl } from '@/utils/network'
export type DelaySnapshot = {
of: (member: ResolvedProxyMember) => number
}
const hashKey = (member: ResolvedProxyMember, group: string) => {
if (member.kind !== 'node')
return JSON.stringify([group, member.kind, member.ref.name])
const source = member.node.source
return JSON.stringify([
group,
source.kind,
source.kind === 'provider' ? source.providerName : null,
source.proxyName,
])
}
export interface DelayUpdate {
delay: number
elapsed?: number
updatedAt: number
}
const CACHE_TTL = 30 * 60 * 1000
class DelayManager {
private cache = new Map<string, DelayUpdate & { url: string }>()
private urlMap = new Map<string, string>()
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
private groupListenerMap = new Map<string, Set<() => void>>()
// Consumers compare snapshot identity; replace it only when the group settles.
private groupSnapshots = new Map<string, DelaySnapshot>()
private groupSetSnapshots = new Map<
string,
ReadonlyMap<string, DelaySnapshot>
>()
// Suppress sort notifications until every measurement in a group batch settles.
private activeBatches = new Map<string, number>()
private pendingItemUpdates = new Map<string, DelayUpdate[]>()
private pendingGroupUpdates = new Set<string>()
private itemFlushScheduled = false
private groupFlushScheduled = false
private scheduleOnNextFrame(run: () => void): void {
if (typeof window !== 'undefined') {
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(run)
return
}
if (typeof window.setTimeout === 'function') {
window.setTimeout(run, 0)
return
}
}
Promise.resolve().then(run)
}
private scheduleItemFlush() {
if (this.itemFlushScheduled) return
this.itemFlushScheduled = true
this.scheduleOnNextFrame(() => {
this.itemFlushScheduled = false
const updates = this.pendingItemUpdates
this.pendingItemUpdates = new Map()
updates.forEach((queue, key) => {
const listener = this.listenerMap.get(key)
if (!listener) return
queue.forEach((update) => {
try {
listener(update)
} catch (error) {
console.error(
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
error,
)
}
})
})
})
}
private scheduleGroupFlush() {
if (this.groupFlushScheduled) return
this.groupFlushScheduled = true
this.scheduleOnNextFrame(() => {
this.groupFlushScheduled = false
const groups = this.pendingGroupUpdates
this.pendingGroupUpdates = new Set()
groups.forEach((group) => {
const listeners = this.groupListenerMap.get(group)
if (!listeners) return
// Copied before iterating: a listener is free to unsubscribe as it runs.
for (const listener of [...listeners]) {
try {
listener()
} catch (error) {
console.error(
`[DelayManager] 通知分组延迟监听器失败: ${group}`,
error,
)
}
}
})
})
}
private queueGroupNotification(group: string) {
if ((this.activeBatches.get(group) ?? 0) > 0) return
// Invalidate the set wrapper while retaining unaffected per-group identities.
this.groupSnapshots.delete(group)
this.groupSetSnapshots.clear()
this.pendingGroupUpdates.add(group)
this.scheduleGroupFlush()
}
/** Cached set snapshot for `useSyncExternalStore` identity comparisons. */
groupsDelays(groupKey: string): ReadonlyMap<string, DelaySnapshot> {
const cached = this.groupSetSnapshots.get(groupKey)
if (cached) return cached
const names = groupKey ? groupKey.split(' ') : []
const snapshots = new Map(
names.map((name) => [name, this.groupDelays(name)]),
)
this.groupSetSnapshots.set(groupKey, snapshots)
return snapshots
}
/** Cached group snapshot whose identity changes only when that group settles. */
groupDelays(group: string): DelaySnapshot {
const existing = this.groupSnapshots.get(group)
if (existing) return existing
const snapshot: DelaySnapshot = {
of: (member) => this.getDelayFix(member, group),
}
this.groupSnapshots.set(group, snapshot)
return snapshot
}
setUrl(group: string, url: string) {
const previous = this.getUrl(group)
if (isValidLatencyTestUrl(url)) {
this.urlMap.set(group, url.trim())
} else {
this.urlMap.delete(group)
}
if (previous !== this.getUrl(group)) this.queueGroupNotification(group)
}
getUrl(group: string) {
const url = this.urlMap.get(group)
debugLog(
`[DelayManager] 获取测试URL,组: ${group}, URL: ${url || '未设置'}`,
)
return url || 'http://cp.cloudflare.com/generate_204'
}
setListener(
member: ResolvedProxyMember,
group: string,
listener: (update: DelayUpdate) => void,
) {
const key = hashKey(member, group)
this.listenerMap.set(key, listener)
}
removeListener(member: ResolvedProxyMember, group: string) {
const key = hashKey(member, group)
this.listenerMap.delete(key)
}
/** Multiple views may subscribe independently; notifications occur only after settle. */
addGroupListener(group: string, listener: () => void): () => void {
const listeners = this.groupListenerMap.get(group) ?? new Set()
listeners.add(listener)
this.groupListenerMap.set(group, listeners)
return () => {
const current = this.groupListenerMap.get(group)
if (!current) return
current.delete(listener)
if (current.size === 0) this.groupListenerMap.delete(group)
}
}
setDelay(
member: ResolvedProxyMember,
group: string,
delay: number,
meta?: { elapsed?: number; url?: string },
): DelayUpdate {
const key = hashKey(member, group)
debugLog(
`[DelayManager] 设置延迟,代理: ${member.ref.name}, 组: ${group}, 延迟: ${delay}`,
)
const update: DelayUpdate = {
delay,
elapsed: meta?.elapsed,
updatedAt: Date.now(),
}
const url = meta?.url ?? this.getUrl(group)
if (url !== this.getUrl(group)) return update
this.cache.set(key, { ...update, url })
const queue = this.pendingItemUpdates.get(key)
if (queue) {
queue.push(update)
} else {
this.pendingItemUpdates.set(key, [update])
}
this.scheduleItemFlush()
return update
}
getDelayUpdate(member: ResolvedProxyMember, group: string) {
const history = this.getHistory(member, group)
const key = hashKey(member, group)
const cached = this.cache.get(key)
const entry = cached?.url === this.getUrl(group) ? cached : undefined
const latest = history?.at(-1)
const updatedAt = latest ? Date.parse(latest.time) : 0
if (latest && entry?.delay !== -2 && updatedAt > (entry?.updatedAt ?? 0)) {
return { delay: latest.delay || 1e6, updatedAt }
}
if (!entry) return undefined
if (Date.now() - entry.updatedAt > CACHE_TTL) {
this.cache.delete(key)
return undefined
}
return { ...entry }
}
getDelay(member: ResolvedProxyMember, group: string) {
const update = this.getDelayUpdate(member, group)
return update ? update.delay : -1
}
getHistory(member: ResolvedProxyMember, group: string) {
return memberDetails(member)?.extra?.[this.getUrl(group)]?.history
}
getDelayFix(member: ResolvedProxyMember, group: string) {
if (member.kind === 'unresolved') return -1
const history =
this.getHistory(member, group) ?? memberDetails(member)?.history
const update = this.getDelayUpdate(member, group)
if (update && (update.delay >= 0 || update.delay === -2)) {
return update.delay
}
if (history?.length) return history[history.length - 1].delay || 1e6
return -1
}
async unifiedDelayCheck(
name: string,
url: string,
timeout: number,
providerName?: string,
) {
if (providerName)
return healthcheckNodeInProvider(providerName, name, url, timeout)
return delayProxyByName(name, url, timeout)
}
/** A single test may notify immediately; a batch defers notification until it settles. */
async checkDelay(
member: InteractableProxyMember,
group: string,
timeout: number,
): Promise<DelayUpdate> {
const update = await this.measureDelay(member, group, timeout)
this.queueGroupNotification(group)
return update
}
private async measureDelay(
member: InteractableProxyMember,
group: string,
timeout: number,
): Promise<DelayUpdate> {
const name = member.ref.name
const providerName =
member.kind === 'node' ? providerNameOf(member.node) : undefined
const apiName =
member.kind === 'node' && member.node.source.kind === 'provider'
? member.node.source.proxyName
: name
debugLog(
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
)
const url = this.getUrl(group)
this.setDelay(member, group, -2, { url })
const startTime = Date.now()
try {
debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`)
const timeoutPromise = new Promise<ProxyDelay>((resolve) => {
setTimeout(() => resolve({ delay: 0 }), timeout)
})
const result = await Promise.race([
this.unifiedDelayCheck(apiName, url, timeout, providerName),
timeoutPromise,
])
const elapsedTime = Date.now() - startTime
if (elapsedTime < 500) {
await new Promise((resolve) => setTimeout(resolve, 500 - elapsedTime))
}
const delay = result.delay
const elapsed = elapsedTime
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
return this.setDelay(member, group, delay, { elapsed, url })
} catch (error) {
await new Promise((resolve) => setTimeout(resolve, 500))
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
const delay = 1e6 // error
const elapsed = Date.now() - startTime
return this.setDelay(member, group, delay, { elapsed, url })
}
}
async checkListDelay(
proxies: InteractableProxyMember[],
group: string,
timeout: number,
concurrency = 36,
) {
debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
)
this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1)
proxies.forEach((member) => {
this.setDelay(member, group, -2)
})
let index = 0
const startTime = Date.now()
const help = async (): Promise<void> => {
const currMember = proxies[index++]
if (!currMember) return
const currName = currMember.ref.name
try {
this.setDelay(currMember, group, -2)
// Stagger requests so a batch does not hit the core at once.
if (index > 1) {
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200),
)
}
// Do not reorder a list around the pointer while batch results are arriving.
await this.measureDelay(currMember, group, timeout)
} catch (error) {
console.error(
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error,
)
this.setDelay(currMember, group, 1e6)
}
return help()
}
const actualConcurrency = Math.min(concurrency, proxies.length, 10)
debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`)
const promiseList: Promise<void>[] = []
for (let i = 0; i < actualConcurrency; i++) {
promiseList.push(help())
}
try {
await Promise.all(promiseList)
} finally {
// Always release the batch and notify; otherwise failures leave stale sort state.
const remaining = (this.activeBatches.get(group) ?? 1) - 1
if (remaining > 0) {
this.activeBatches.set(group, remaining)
} else {
this.activeBatches.delete(group)
this.queueGroupNotification(group)
}
}
const totalTime = Date.now() - startTime
debugLog(
`[DelayManager] 批量测试延迟完成,组: ${group}, 总耗时: ${totalTime}ms`,
)
}
formatDelay(delay: number, timeout = DEFAULT_DELAY_TIMEOUT) {
switch (classifyDelay(delay, timeout)) {
case 'untested':
return '-'
case 'testing':
return 'testing'
case 'timeout':
return 'Timeout'
case 'error':
return 'Error'
case 'measured':
return `${delay}`
}
}
formatDelayColor(delay: number, timeout = DEFAULT_DELAY_TIMEOUT) {
switch (classifyDelay(delay, timeout)) {
case 'untested':
case 'testing':
return ''
case 'timeout':
case 'error':
return 'error.main'
case 'measured':
// Colour and signal bars intentionally use different grading thresholds.
if (delay >= 400) return 'warning.main'
if (delay >= 250) return 'primary.main'
return 'success.main'
}
}
}
export default new DelayManager()