mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
feat: add custom network latency targets
This commit is contained in:
@@ -24,6 +24,7 @@ export const defaultConfig: IAppConfig = {
|
||||
autoCheckUpdate: true,
|
||||
autoCloseConnection: true,
|
||||
subscriptionTimeout: 30000,
|
||||
networkLatencyTargets: [],
|
||||
useNameserverPolicy: false,
|
||||
controlDns: true,
|
||||
controlSniff: true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "مشاهده پیکربندی اجرا",
|
||||
|
||||
@@ -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": "Просмотр текущей конфигурации",
|
||||
|
||||
@@ -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": "查看运行时配置",
|
||||
|
||||
@@ -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": "查看運行時配置",
|
||||
|
||||
@@ -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,17 +348,20 @@ 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>
|
||||
)
|
||||
})}
|
||||
|
||||
6
src/shared/types.d.ts
vendored
6
src/shared/types.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user