feat(proxies): add proxy latency test history timeline

This commit is contained in:
kunish 2024-10-12 22:20:51 +08:00
parent 8252d04985
commit 1e7dcb13bc
No known key found for this signature in database
GPG Key ID: 67D3ACD788F3A7CD
8 changed files with 253 additions and 82 deletions

View File

@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@commitlint/config-conventional": "^19.5.0", "@commitlint/config-conventional": "^19.5.0",
"@corvu/tooltip": "^0.2.1",
"@eslint/js": "^9.12.0", "@eslint/js": "^9.12.0",
"@felte/solid": "^1.2.13", "@felte/solid": "^1.2.13",
"@felte/validator-zod": "^1.0.17", "@felte/validator-zod": "^1.0.17",

View File

@ -10,6 +10,9 @@ importers:
'@commitlint/config-conventional': '@commitlint/config-conventional':
specifier: ^19.5.0 specifier: ^19.5.0
version: 19.5.0 version: 19.5.0
'@corvu/tooltip':
specifier: ^0.2.1
version: 0.2.1(solid-js@1.9.2)
'@eslint/js': '@eslint/js':
specifier: ^9.12.0 specifier: ^9.12.0
version: 9.12.0 version: 9.12.0
@ -1257,6 +1260,22 @@ packages:
} }
engines: { node: '>=v18' } engines: { node: '>=v18' }
'@corvu/tooltip@0.2.1':
resolution:
{
integrity: sha512-y2CQ2/6DH/gJJZPo1fV3O7l4Jfgu5ZW58bpqPmKS+l8Pa6gIKV6zkrMoyBg8Hsn6z9RmdZFqkGFs/9C5fvwKpg==,
}
peerDependencies:
solid-js: ^1.8
'@corvu/utils@0.4.2':
resolution:
{
integrity: sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA==,
}
peerDependencies:
solid-js: ^1.8
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: resolution:
{ {
@ -1554,6 +1573,24 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.2.0 zod: ^3.2.0
'@floating-ui/core@1.6.8':
resolution:
{
integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==,
}
'@floating-ui/dom@1.6.11':
resolution:
{
integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==,
}
'@floating-ui/utils@0.2.8':
resolution:
{
integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==,
}
'@fontsource/fira-sans@5.1.0': '@fontsource/fira-sans@5.1.0':
resolution: resolution:
{ {
@ -5331,12 +5368,28 @@ packages:
apexcharts: ^3.40.0 apexcharts: ^3.40.0
solid-js: ^1.6.0 solid-js: ^1.6.0
solid-dismissible@0.1.1:
resolution:
{
integrity: sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA==,
}
peerDependencies:
solid-js: ^1.8
solid-js@1.9.2: solid-js@1.9.2:
resolution: resolution:
{ {
integrity: sha512-fe/K03nV+kMFJYhAOE8AIQHcGxB4rMIEoEyrulbtmf217NffbbwBqJnJI4ovt16e+kaIt0czE2WA7mP/pYN9yg==, integrity: sha512-fe/K03nV+kMFJYhAOE8AIQHcGxB4rMIEoEyrulbtmf217NffbbwBqJnJI4ovt16e+kaIt0czE2WA7mP/pYN9yg==,
} }
solid-presence@0.1.8:
resolution:
{
integrity: sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA==,
}
peerDependencies:
solid-js: ^1.8
solid-refresh@0.6.3: solid-refresh@0.6.3:
resolution: resolution:
{ {
@ -7228,6 +7281,19 @@ snapshots:
'@types/conventional-commits-parser': 5.0.0 '@types/conventional-commits-parser': 5.0.0
chalk: 5.3.0 chalk: 5.3.0
'@corvu/tooltip@0.2.1(solid-js@1.9.2)':
dependencies:
'@corvu/utils': 0.4.2(solid-js@1.9.2)
'@floating-ui/dom': 1.6.11
solid-dismissible: 0.1.1(solid-js@1.9.2)
solid-js: 1.9.2
solid-presence: 0.1.8(solid-js@1.9.2)
'@corvu/utils@0.4.2(solid-js@1.9.2)':
dependencies:
'@floating-ui/dom': 1.6.11
solid-js: 1.9.2
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
optional: true optional: true
@ -7352,6 +7418,17 @@ snapshots:
'@felte/common': 1.1.8 '@felte/common': 1.1.8
zod: 3.23.8 zod: 3.23.8
'@floating-ui/core@1.6.8':
dependencies:
'@floating-ui/utils': 0.2.8
'@floating-ui/dom@1.6.11':
dependencies:
'@floating-ui/core': 1.6.8
'@floating-ui/utils': 0.2.8
'@floating-ui/utils@0.2.8': {}
'@fontsource/fira-sans@5.1.0': {} '@fontsource/fira-sans@5.1.0': {}
'@humanfs/core@0.19.0': {} '@humanfs/core@0.19.0': {}
@ -9495,12 +9572,22 @@ snapshots:
defu: 6.1.4 defu: 6.1.4
solid-js: 1.9.2 solid-js: 1.9.2
solid-dismissible@0.1.1(solid-js@1.9.2):
dependencies:
'@corvu/utils': 0.4.2(solid-js@1.9.2)
solid-js: 1.9.2
solid-js@1.9.2: solid-js@1.9.2:
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
seroval: 1.1.0 seroval: 1.1.0
seroval-plugins: 1.1.0(seroval@1.1.0) seroval-plugins: 1.1.0(seroval@1.1.0)
solid-presence@0.1.8(solid-js@1.9.2):
dependencies:
'@corvu/utils': 0.4.2(solid-js@1.9.2)
solid-js: 1.9.2
solid-refresh@0.6.3(solid-js@1.9.2): solid-refresh@0.6.3(solid-js@1.9.2):
dependencies: dependencies:
'@babel/generator': 7.24.7 '@babel/generator': 7.24.7

View File

@ -1,36 +1,28 @@
import { LATENCY_QUALITY_MAP_HTTP } from '~/constants' import { JSX, ParentComponent } from 'solid-js'
import { useI18n } from '~/i18n' import { twMerge } from 'tailwind-merge'
import { latencyQualityMap, useProxies } from '~/signals' import { getLatencyClassName } from '~/helpers'
import { useProxies } from '~/signals'
export const Latency = (props: { name?: string; class?: string }) => { interface Props extends JSX.HTMLAttributes<HTMLSpanElement> {
const [t] = useI18n() proxyName: string
}
export const Latency: ParentComponent<Props> = (props) => {
const [local, others] = splitProps(props, ['class'])
const { getLatencyByName } = useProxies() const { getLatencyByName } = useProxies()
const [textClassName, setTextClassName] = createSignal('') const [textClassName, setTextClassName] = createSignal('')
const latency = createMemo(() => getLatencyByName(props.name || '')) const latency = createMemo(() => getLatencyByName(others.proxyName || ''))
createEffect(() => { createEffect(() => {
if (latency() > latencyQualityMap().HIGH) { setTextClassName(getLatencyClassName(latency()))
setTextClassName('text-error')
} else if (latency() > latencyQualityMap().MEDIUM) {
setTextClassName('text-warning')
} else {
setTextClassName('text-success')
}
}) })
return ( return (
<Show <span
when={ class={twMerge('badge whitespace-nowrap', textClassName(), local.class)}
typeof latency() === 'number' && {...others}
latency() !== LATENCY_QUALITY_MAP_HTTP.NOT_CONNECTED
}
> >
<span {latency() || '-'}
class={`whitespace-nowrap text-xs ${textClassName()} ${props.class}`} </span>
>
{latency()}
{t('ms')}
</span>
</Show>
) )
} }

View File

@ -1,8 +1,14 @@
import { IconBrandSpeedtest } from '@tabler/icons-solidjs' import Tooltip from '@corvu/tooltip'
import { IconCircleCheckFilled } from '@tabler/icons-solidjs'
import dayjs from 'dayjs'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { Button, Latency } from '~/components' import { Latency } from '~/components'
import { filterSpecialProxyType, formatProxyType } from '~/helpers' import {
import { useProxies } from '~/signals' filterSpecialProxyType,
formatProxyType,
getLatencyClassName,
} from '~/helpers'
import { curTheme, useProxies } from '~/signals'
export const ProxyNodeCard = (props: { export const ProxyNodeCard = (props: {
proxyName: string proxyName: string
@ -32,56 +38,114 @@ export const ProxyNodeCard = (props: {
: null : null
return ( return (
<div <Tooltip
class={twMerge( placement="top"
'card tooltip card-compact tooltip-accent relative bg-neutral text-neutral-content', floatingOptions={{
isSelected && 'bg-primary text-primary-content', autoPlacement: true,
onClick && 'cursor-pointer', shift: true,
)} offset: 10,
data-tip={proxyName} }}
onClick={onClick}
> >
<div class="badge badge-secondary badge-sm absolute bottom-0 left-1/2 -translate-x-1/2 font-bold uppercase"> <Tooltip.Anchor
{formatProxyType(proxyNode()?.type)} class={twMerge(
</div> 'card bg-neutral text-neutral-content',
isSelected && 'bg-primary text-primary-content',
onClick && 'cursor-pointer',
)}
title={proxyName}
>
<Tooltip.Trigger>
<div class="card-body p-2.5" onClick={onClick}>
<h2 class="card-title line-clamp-1 text-start text-sm">
{proxyName}
</h2>
<div class="card-body"> <span
<h2 class="card-title line-clamp-1 text-start text-sm">{proxyName}</h2> class={twMerge(
'text-start text-xs',
isSelected ? 'text-info-content' : 'text-neutral-content',
)}
>
{[
specialType(),
supportIPv6() && 'IPv6',
proxyNode().tfo && 'TFO',
]
.filter(Boolean)
.join(' / ')}
</span>
<span <div class="card-actions items-center justify-between">
class={twMerge( <div class="badge badge-secondary badge-sm font-bold uppercase">
'text-start text-xs', {formatProxyType(proxyNode()?.type)}
isSelected ? 'text-info-content' : 'text-neutral-content', </div>
)}
>
{[specialType(), supportIPv6() && 'IPv6'].filter(Boolean).join(' / ')}
</span>
<div class="card-actions items-center justify-end"> <Latency
<Latency proxyName={props.proxyName}
name={props.proxyName}
class={twMerge(isSelected && 'badge')}
/>
<Button
class="btn-square btn-sm"
icon={
<IconBrandSpeedtest
class={twMerge( class={twMerge(
'size-6', proxyLatencyTestingMap()[proxyName] && 'animate-pulse',
proxyLatencyTestingMap()[proxyName] &&
'animate-pulse text-success',
)} )}
/> onClick={(e) => {
} e.stopPropagation()
onClick={(e) => {
e.stopPropagation()
void proxyLatencyTest(proxyName, proxyNode().provider) void proxyLatencyTest(proxyName, proxyNode().provider)
}} }}
/> />
</div> </div>
</div> </div>
</div> </Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content data-theme={curTheme()} class="z-50 bg-transparent">
<Tooltip.Arrow class="text-neutral" />
<div class="flex flex-col items-center gap-2 rounded-box bg-neutral p-2.5 text-neutral-content">
<h2 class="text-lg font-bold">{proxyName}</h2>
<ul class="timeline timeline-vertical timeline-compact timeline-snap-icon">
<For each={proxyNode().latencyTestHistory}>
{(latencyTestResult, index) => (
<li>
<Show when={index() > 0}>
<hr />
</Show>
<div class="timeline-start space-y-2">
<time class="text-sm italic">
{dayjs(latencyTestResult.time).format(
'YYYY-MM-DD HH:mm:ss',
)}
</time>
<div
class={twMerge(
'badge block',
getLatencyClassName(latencyTestResult.delay),
)}
>
{latencyTestResult.delay || '-'}
</div>
</div>
<div class="timeline-middle">
<IconCircleCheckFilled class="size-4" />
</div>
<Show
when={
index() !== proxyNode().latencyTestHistory.length - 1
}
>
<hr />
</Show>
</li>
)}
</For>
</ul>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Anchor>
</Tooltip>
) )
} }

View File

@ -68,7 +68,7 @@ export const ProxyPreviewBar = (props: {
/> />
</div> </div>
<Latency name={props.now} /> <Latency proxyName={props.now} />
</div> </div>
) )
} }

View File

@ -65,7 +65,7 @@ export const ProxyPreviewDots = (props: {
</For> </For>
</div> </div>
<Latency name={props.now} /> <Latency proxyName={props.now} />
</div> </div>
) )
} }

View File

@ -1,4 +1,4 @@
import { PROXIES_ORDERING_TYPE } from '~/constants' import { LATENCY_QUALITY_MAP_HTTP, PROXIES_ORDERING_TYPE } from '~/constants'
import { latencyQualityMap, useProxies } from '~/signals' import { latencyQualityMap, useProxies } from '~/signals'
export const formatProxyType = (type = '') => { export const formatProxyType = (type = '') => {
@ -19,6 +19,18 @@ export const formatProxyType = (type = '') => {
return t return t
} }
export const getLatencyClassName = (latency: LATENCY_QUALITY_MAP_HTTP) => {
if (latency > latencyQualityMap().HIGH) {
return 'text-error'
} else if (latency > latencyQualityMap().MEDIUM) {
return 'text-warning'
} else if (latency === LATENCY_QUALITY_MAP_HTTP.NOT_CONNECTED) {
return 'text-neutral-content'
} else {
return 'text-success'
}
}
export const filterSpecialProxyType = (type = '') => { export const filterSpecialProxyType = (type = '') => {
const t = type.toLowerCase() const t = type.toLowerCase()
const conditions = [ const conditions = [

View File

@ -24,7 +24,12 @@ import type { Proxy, ProxyNode, ProxyProvider } from '~/types'
type ProxyInfo = { type ProxyInfo = {
name: string name: string
udp: boolean udp: boolean
now: string tfo: boolean
latencyTestHistory: {
time: string
delay: number
}[]
latency: string
xudp: boolean xudp: boolean
type: string type: string
provider: string provider: string
@ -99,9 +104,19 @@ const setProxiesInfo = (
const newProxyIPv6SupportMap = { ...proxyIPv6SupportMap() } const newProxyIPv6SupportMap = { ...proxyIPv6SupportMap() }
proxies.forEach((proxy) => { proxies.forEach((proxy) => {
const { udp, xudp, type, now, name, provider = '' } = proxy const { udp, xudp, type, now, history, name, tfo, provider = '' } = proxy
newProxyNodeMap[proxy.name] = {
udp,
xudp,
type,
latency: now,
latencyTestHistory: history,
name,
tfo,
provider,
}
newProxyNodeMap[proxy.name] = { udp, xudp, type, now, name, provider }
newLatencyMap[proxy.name] = getLatencyFromProxy(proxy, urlForLatencyTest()) newLatencyMap[proxy.name] = getLatencyFromProxy(proxy, urlForLatencyTest())
// we don't set it when false because sing-box didn't have "extra" so it will always be false // we don't set it when false because sing-box didn't have "extra" so it will always be false
@ -326,8 +341,8 @@ export const useProxies = () => {
return name return name
} }
while (node.now && node.now !== node.name) { while (node.latency && node.latency !== node.name) {
const nextNode = proxyNodeMap()[node.now] const nextNode = proxyNodeMap()[node.latency]
if (!nextNode) { if (!nextNode) {
return node.name return node.name
@ -353,7 +368,7 @@ export const useProxies = () => {
return ( return (
['direct', 'reject', 'loadbalance'].includes( ['direct', 'reject', 'loadbalance'].includes(
proxyNode.type.toLowerCase(), proxyNode.type.toLowerCase(),
) || !!proxyNode.now ) || !!proxyNode.latency
) )
} }