feat: add custom network latency targets

This commit is contained in:
zjdndjf
2026-04-26 22:17:19 +08:00
parent 9ea9da7d58
commit ef788eff57
8 changed files with 291 additions and 14 deletions

View File

@@ -24,6 +24,7 @@ export const defaultConfig: IAppConfig = {
autoCheckUpdate: true,
autoCloseConnection: true,
subscriptionTimeout: 30000,
networkLatencyTargets: [],
useNameserverPolicy: false,
controlDns: true,
controlSniff: true,

View File

@@ -345,6 +345,11 @@
"network.latency.title": "Network Latency",
"network.latency.average": "Avg",
"network.latency.timeout": "Timeout",
"network.latency.namePlaceholder": "Name",
"network.latency.urlPlaceholder": "Domain or URL",
"network.latency.add": "Add address",
"network.latency.invalidName": "Enter a name",
"network.latency.invalidUrl": "Enter a valid HTTP/HTTPS address",
"sider.cards.config": "Runtime Config",
"sider.cards.emptyProfile": "Empty Profile",
"sider.cards.viewRuntimeConfig": "View Runtime Config",

View File

@@ -320,6 +320,11 @@
"network.latency.title": "تاخیر شبکه",
"network.latency.average": "میانگین",
"network.latency.timeout": "وقفه",
"network.latency.namePlaceholder": "نام",
"network.latency.urlPlaceholder": "دامنه یا URL",
"network.latency.add": "افزودن آدرس",
"network.latency.invalidName": "نام را وارد کنید",
"network.latency.invalidUrl": "یک آدرس HTTP/HTTPS معتبر وارد کنید",
"sider.cards.config": "پیکربندی اجرا",
"sider.cards.emptyProfile": "پروفایل خالی",
"sider.cards.viewRuntimeConfig": "مشاهده پیکربندی اجرا",

View File

@@ -322,6 +322,11 @@
"network.latency.title": "Задержка сети",
"network.latency.average": "Среднее",
"network.latency.timeout": "Таймаут",
"network.latency.namePlaceholder": "Название",
"network.latency.urlPlaceholder": "Домен или URL",
"network.latency.add": "Добавить адрес",
"network.latency.invalidName": "Введите название",
"network.latency.invalidUrl": "Введите корректный HTTP/HTTPS адрес",
"sider.cards.config": "Конфигурация",
"sider.cards.emptyProfile": "Пустой профиль",
"sider.cards.viewRuntimeConfig": "Просмотр текущей конфигурации",

View File

@@ -345,6 +345,11 @@
"network.latency.title": "网络延迟",
"network.latency.average": "平均",
"network.latency.timeout": "超时",
"network.latency.namePlaceholder": "名称",
"network.latency.urlPlaceholder": "域名或 URL",
"network.latency.add": "添加地址",
"network.latency.invalidName": "请输入名称",
"network.latency.invalidUrl": "请输入有效的 HTTP/HTTPS 地址",
"sider.cards.config": "运行时配置",
"sider.cards.emptyProfile": "空白配置",
"sider.cards.viewRuntimeConfig": "查看运行时配置",

View File

@@ -345,6 +345,11 @@
"network.latency.title": "網路延遲",
"network.latency.average": "平均",
"network.latency.timeout": "逾時",
"network.latency.namePlaceholder": "名稱",
"network.latency.urlPlaceholder": "網域或 URL",
"network.latency.add": "新增地址",
"network.latency.invalidName": "請輸入名稱",
"network.latency.invalidUrl": "請輸入有效的 HTTP/HTTPS 地址",
"sider.cards.config": "運行時配置",
"sider.cards.emptyProfile": "空白配置",
"sider.cards.viewRuntimeConfig": "查看運行時配置",

View File

@@ -1,17 +1,20 @@
import BasePage from '@renderer/components/base/base-page'
import NetworkTopologyCard from '@renderer/components/network/network-topology'
import React, { useState, useEffect, useCallback } from 'react'
import { Button, Select, SelectItem, Chip, Tooltip } from '@heroui/react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Button, Select, SelectItem, Chip, Tooltip, Input } from '@heroui/react'
import {
IoRefresh,
IoCopyOutline,
IoCheckmark,
IoEyeOutline,
IoEyeOffOutline
IoEyeOffOutline,
IoAdd,
IoClose
} from 'react-icons/io5'
import { IoMdGlobe, IoMdPulse } from 'react-icons/io'
import { useTranslation } from 'react-i18next'
import { fetchIPInfo, measureLatency } from '@renderer/utils/ipc'
import { useAppConfig } from '@renderer/hooks/use-app-config'
type IPProvider = 'ip.sb' | 'ipwho.is' | 'ipapi.is'
@@ -104,12 +107,68 @@ interface LatencyResult {
status: LatencyStatus
}
const LATENCY_TARGETS = [
interface LatencyTarget {
name: string
url: string
custom?: boolean
}
const DEFAULT_LATENCY_TARGETS: LatencyTarget[] = [
{ name: 'Google', url: 'https://www.google.com/generate_204' },
{ name: 'Cloudflare', url: 'https://www.cloudflare.com/cdn-cgi/trace' },
{ name: 'GitHub', url: 'https://github.com' }
{ name: 'GitHub', url: 'https://github.com/' }
]
function normalizeLatencyUrl(value: string): string | null {
const trimmed = value.trim()
if (!trimmed) return null
const urlText = /^[a-z][a-z\d+\-.]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
try {
const url = new URL(urlText)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.toString()
} catch {
return null
}
}
function normalizeLatencyTarget(
value: string | Partial<INetworkLatencyTarget>
): INetworkLatencyTarget | null {
if (typeof value === 'string') {
const url = normalizeLatencyUrl(value)
if (!url) return null
return { name: getLatencyTargetName(url), url }
}
const url = normalizeLatencyUrl(value.url ?? '')
if (!url) return null
return { name: value.name?.trim() || getLatencyTargetName(url), url }
}
function normalizeLatencyTargets(
values: Array<string | Partial<INetworkLatencyTarget>> = []
): INetworkLatencyTarget[] {
const result: INetworkLatencyTarget[] = []
const seen = new Set<string>()
for (const value of values) {
const target = normalizeLatencyTarget(value)
if (!target || seen.has(target.url)) continue
seen.add(target.url)
result.push(target)
}
return result
}
function getLatencyTargetName(url: string): string {
try {
return new URL(url).hostname || url
} catch {
return url
}
}
function latencyColor(latency: number | null): string {
if (latency === null) return ''
if (latency < 100) return 'text-success'
@@ -151,27 +210,129 @@ const InfoRow: React.FC<InfoRowProps> = ({ label, value, mono }) => (
const IPPage: React.FC = () => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig()
const [provider, setProvider] = useState<IPProvider>('ip.sb')
const [ipInfo, setIpInfo] = useState<IPInfo | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [hidden, setHidden] = useState(false)
const [isAddingLatencyTarget, setIsAddingLatencyTarget] = useState(false)
const [customLatencyName, setCustomLatencyName] = useState('')
const [customLatencyUrl, setCustomLatencyUrl] = useState('')
const [customLatencyNameError, setCustomLatencyNameError] = useState(false)
const [customLatencyUrlError, setCustomLatencyUrlError] = useState(false)
// latency state: map from url -> result
const [latencyResults, setLatencyResults] = useState<Record<string, LatencyResult>>({})
const [testingLatency, setTestingLatency] = useState(false)
const appConfigLoaded = appConfig !== undefined
const customLatencyTargets = useMemo(
() => normalizeLatencyTargets(appConfig?.networkLatencyTargets),
[appConfig?.networkLatencyTargets]
)
const latencyTargets = useMemo(() => {
const defaultUrls = new Set(DEFAULT_LATENCY_TARGETS.map((target) => target.url))
return [
...DEFAULT_LATENCY_TARGETS,
...customLatencyTargets
.filter((target) => !defaultUrls.has(target.url))
.map((target) => ({ ...target, custom: true }))
]
}, [customLatencyTargets])
const patchCustomLatencyTargets = useCallback(
async (targets: Array<string | Partial<INetworkLatencyTarget>>) => {
const defaultUrls = new Set(DEFAULT_LATENCY_TARGETS.map((target) => target.url))
await patchAppConfig({
networkLatencyTargets: normalizeLatencyTargets(targets).filter(
(target) => !defaultUrls.has(target.url)
)
})
},
[patchAppConfig]
)
const closeLatencyTargetForm = useCallback(() => {
setIsAddingLatencyTarget(false)
setCustomLatencyName('')
setCustomLatencyUrl('')
setCustomLatencyNameError(false)
setCustomLatencyUrlError(false)
}, [])
const addCustomLatencyTarget = useCallback(async () => {
const name = customLatencyName.trim()
const url = normalizeLatencyUrl(customLatencyUrl)
setCustomLatencyNameError(!name)
setCustomLatencyUrlError(!url)
if (!name || !url) return
const existingUrls = new Set(DEFAULT_LATENCY_TARGETS.map((target) => target.url))
if (!existingUrls.has(url)) {
await patchCustomLatencyTargets([
...customLatencyTargets.filter((target) => target.url !== url),
{ name, url }
])
}
closeLatencyTargetForm()
}, [
closeLatencyTargetForm,
customLatencyName,
customLatencyTargets,
customLatencyUrl,
patchCustomLatencyTargets
])
const openLatencyTargetForm = useCallback(() => {
setIsAddingLatencyTarget(true)
}, [])
const updateCustomLatencyName = useCallback((value: string) => {
setCustomLatencyName(value)
setCustomLatencyNameError(false)
}, [])
const updateCustomLatencyUrl = useCallback((value: string) => {
setCustomLatencyUrl(value)
setCustomLatencyUrlError(false)
}, [])
const submitLatencyTargetOnEnter = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter') return
event.preventDefault()
addCustomLatencyTarget()
},
[addCustomLatencyTarget]
)
const removeCustomLatencyTarget = useCallback(
async (url: string) => {
await patchCustomLatencyTargets(
customLatencyTargets.filter((targetUrl) => targetUrl.url !== url)
)
setLatencyResults((prev) => {
const next = { ...prev }
delete next[url]
return next
})
},
[customLatencyTargets, patchCustomLatencyTargets]
)
const testAllLatencies = useCallback(async () => {
setTestingLatency(true)
// mark all as pending first
setLatencyResults(
Object.fromEntries(
LATENCY_TARGETS.map((t) => [t.url, { latency: null, status: 'pending' as LatencyStatus }])
latencyTargets.map((t) => [t.url, { latency: null, status: 'pending' as LatencyStatus }])
)
)
await Promise.all(
LATENCY_TARGETS.map(async (target) => {
latencyTargets.map(async (target) => {
try {
const latency = await measureLatency(target.url)
setLatencyResults((prev) => ({
@@ -187,16 +348,19 @@ const IPPage: React.FC = () => {
})
)
setTestingLatency(false)
}, [])
}, [latencyTargets])
useEffect(() => {
if (!appConfigLoaded) return
testAllLatencies()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [appConfigLoaded, testAllLatencies])
const averageLatency = (() => {
const successes = LATENCY_TARGETS.map((t) => latencyResults[t.url]).filter(
(r): r is LatencyResult & { latency: number } => r?.status === 'success' && r.latency !== null
const successes = latencyTargets
.map((t) => latencyResults[t.url])
.filter(
(r): r is LatencyResult & { latency: number } =>
r?.status === 'success' && r.latency !== null
)
if (successes.length === 0) return null
return Math.round(successes.reduce((acc, r) => acc + r.latency, 0) / successes.length)
@@ -414,6 +578,18 @@ const IPPage: React.FC = () => {
{t('network.latency.average')}: {averageLatency}ms
</span>
)}
<Tooltip content={t('network.latency.add')}>
<Button
size="sm"
isIconOnly
variant={isAddingLatencyTarget ? 'flat' : 'light'}
className="h-7 w-7 min-w-0"
aria-label={t('network.latency.add')}
onPress={openLatencyTargetForm}
>
<IoAdd size={16} />
</Button>
</Tooltip>
<Button
size="sm"
isIconOnly
@@ -428,8 +604,60 @@ const IPPage: React.FC = () => {
</div>
</div>
{isAddingLatencyTarget && (
<div className="mb-3 flex flex-wrap items-start gap-2">
<Input
size="sm"
className="min-w-32 flex-1"
value={customLatencyName}
placeholder={t('network.latency.namePlaceholder')}
aria-label={t('network.latency.namePlaceholder')}
isInvalid={customLatencyNameError}
errorMessage={customLatencyNameError ? t('network.latency.invalidName') : undefined}
onValueChange={updateCustomLatencyName}
onKeyDown={submitLatencyTargetOnEnter}
/>
<Input
size="sm"
className="min-w-48 flex-[1.5]"
value={customLatencyUrl}
placeholder={t('network.latency.urlPlaceholder')}
aria-label={t('network.latency.urlPlaceholder')}
isInvalid={customLatencyUrlError}
errorMessage={customLatencyUrlError ? t('network.latency.invalidUrl') : undefined}
onValueChange={updateCustomLatencyUrl}
onKeyDown={submitLatencyTargetOnEnter}
/>
<Tooltip content={t('common.save')}>
<Button
size="sm"
isIconOnly
variant="flat"
color="primary"
className="h-8 w-8 min-w-0"
aria-label={t('common.save')}
onPress={addCustomLatencyTarget}
>
<IoCheckmark size={16} />
</Button>
</Tooltip>
<Tooltip content={t('common.cancel')}>
<Button
size="sm"
isIconOnly
variant="light"
className="h-8 w-8 min-w-0"
aria-label={t('common.cancel')}
onPress={closeLatencyTargetForm}
>
<IoClose size={16} />
</Button>
</Tooltip>
</div>
)}
<div className="flex flex-col gap-3">
{LATENCY_TARGETS.map((target) => {
{latencyTargets.map((target) => {
const res = latencyResults[target.url]
return (
<div key={target.url} className="flex items-center gap-3">
@@ -458,6 +686,23 @@ const IPPage: React.FC = () => {
<span className="text-danger">{t('network.latency.timeout')}</span>
)}
</span>
{target.custom ? (
<Tooltip content={t('common.delete')}>
<Button
size="sm"
isIconOnly
variant="light"
color="danger"
className="h-6 w-6 min-w-0 shrink-0"
aria-label={t('common.delete')}
onPress={() => removeCustomLatencyTarget(target.url)}
>
<IoClose size={14} />
</Button>
</Tooltip>
) : (
<span className="h-6 w-6 shrink-0" />
)}
</div>
)
})}

View File

@@ -236,6 +236,11 @@ interface ISysProxyConfig {
pacScript?: string
}
interface INetworkLatencyTarget {
name: string
url: string
}
interface IAppConfig {
core: 'mihomo' | 'mihomo-alpha' | 'mihomo-smart' | 'mihomo-specific'
specificVersion?: string
@@ -318,6 +323,7 @@ interface IAppConfig {
delayTestConcurrency?: number
delayTestUrl?: string
delayTestTimeout?: number
networkLatencyTargets?: INetworkLatencyTarget[]
subscriptionTimeout?: number
encryptedPassword?: number[]
controlDns?: boolean