feat(award): 增加评分功能
This commit is contained in:
@@ -8,6 +8,7 @@ export interface IpInfoResponse {
|
|||||||
|
|
||||||
export interface BatchMeasurementResponse {
|
export interface BatchMeasurementResponse {
|
||||||
measurementId: string
|
measurementId: string
|
||||||
|
tracerouteId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchResultResponse {
|
export interface BatchResultResponse {
|
||||||
@@ -16,19 +17,37 @@ export interface BatchResultResponse {
|
|||||||
nodeId: string
|
nodeId: string
|
||||||
latency: number | null
|
latency: number | null
|
||||||
success: boolean
|
success: boolean
|
||||||
|
stats?: {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
loss: number
|
||||||
|
}
|
||||||
}>
|
}>
|
||||||
resolvedAddress?: string
|
resolvedAddress?: string
|
||||||
ipInfo?: IpInfo | null
|
ipInfo?: IpInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TracerouteResponse {
|
||||||
|
status: 'pending' | 'finished'
|
||||||
|
totalHops?: number
|
||||||
|
uniqueAsns?: number
|
||||||
|
hasOptimizedRoute?: boolean
|
||||||
|
detectedPremiumAsns?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedNodeResult {
|
||||||
|
nodeId: string
|
||||||
|
latency: number | null
|
||||||
|
success: boolean
|
||||||
|
stats?: { min: number; max: number; loss: number }
|
||||||
|
}
|
||||||
|
|
||||||
export interface SaveResultRequest {
|
export interface SaveResultRequest {
|
||||||
type: 'single' | 'compare'
|
type: 'single' | 'compare'
|
||||||
input: { target: string } | { leftTarget: string; rightTarget: string }
|
input: { target: string } | { leftTarget: string; rightTarget: string }
|
||||||
results: Array<{ nodeId: string; latency: number | null; success: boolean }> | {
|
results: SavedNodeResult[] | { left: SavedNodeResult[]; right: SavedNodeResult[] }
|
||||||
left: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
|
||||||
right: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
|
||||||
}
|
|
||||||
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
||||||
|
traceroute?: TracerouteStats | { left: TracerouteStats | null; right: TracerouteStats | null } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveResultResponse {
|
export interface SaveResultResponse {
|
||||||
@@ -39,11 +58,9 @@ export interface SaveResultResponse {
|
|||||||
export interface SavedResultData {
|
export interface SavedResultData {
|
||||||
type: 'single' | 'compare'
|
type: 'single' | 'compare'
|
||||||
input: { target: string } | { leftTarget: string; rightTarget: string }
|
input: { target: string } | { leftTarget: string; rightTarget: string }
|
||||||
results: Array<{ nodeId: string; latency: number | null; success: boolean }> | {
|
results: SavedNodeResult[] | { left: SavedNodeResult[]; right: SavedNodeResult[] }
|
||||||
left: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
|
||||||
right: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
|
||||||
}
|
|
||||||
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
||||||
|
traceroute?: TracerouteStats | { left: TracerouteStats | null; right: TracerouteStats | null } | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +71,17 @@ export async function fetchUserIp(): Promise<string> {
|
|||||||
return data.ip
|
return data.ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TracerouteStats {
|
||||||
|
totalHops: number
|
||||||
|
uniqueAsns: number
|
||||||
|
hasOptimizedRoute: boolean
|
||||||
|
detectedPremiumAsns?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestResult {
|
export interface TestResult {
|
||||||
resolvedAddress?: string
|
resolvedAddress?: string
|
||||||
ipInfo?: IpInfo | null
|
ipInfo?: IpInfo | null
|
||||||
|
traceroute?: TracerouteStats | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testAllNodes(
|
export async function testAllNodes(
|
||||||
@@ -80,7 +105,7 @@ export async function testAllNodes(
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { measurementId }: BatchMeasurementResponse = await res.json()
|
const { measurementId, tracerouteId }: BatchMeasurementResponse = await res.json()
|
||||||
|
|
||||||
for (const node of TEST_NODES) {
|
for (const node of TEST_NODES) {
|
||||||
onProgress({ nodeId: node.id, latency: null, status: 'testing' })
|
onProgress({ nodeId: node.id, latency: null, status: 'testing' })
|
||||||
@@ -116,7 +141,8 @@ export async function testAllNodes(
|
|||||||
onProgress({
|
onProgress({
|
||||||
nodeId: result.nodeId,
|
nodeId: result.nodeId,
|
||||||
latency: result.latency,
|
latency: result.latency,
|
||||||
status: 'success'
|
status: 'success',
|
||||||
|
stats: result.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +153,8 @@ export async function testAllNodes(
|
|||||||
onProgress({
|
onProgress({
|
||||||
nodeId: result.nodeId,
|
nodeId: result.nodeId,
|
||||||
latency: result.latency,
|
latency: result.latency,
|
||||||
status: result.success ? 'success' : 'failed'
|
status: result.success ? 'success' : 'failed',
|
||||||
|
stats: result.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +162,28 @@ export async function testAllNodes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { resolvedAddress, ipInfo }
|
// Fetch traceroute results if available
|
||||||
|
let traceroute: TracerouteStats | null = null
|
||||||
|
if (tracerouteId) {
|
||||||
|
try {
|
||||||
|
const trRes = await fetch(`${API_BASE}/latency/traceroute/${tracerouteId}`)
|
||||||
|
if (trRes.ok) {
|
||||||
|
const trData: TracerouteResponse = await trRes.json()
|
||||||
|
if (trData.status === 'finished' && trData.totalHops !== undefined) {
|
||||||
|
traceroute = {
|
||||||
|
totalHops: trData.totalHops,
|
||||||
|
uniqueAsns: trData.uniqueAsns ?? 0,
|
||||||
|
hasOptimizedRoute: trData.hasOptimizedRoute ?? false,
|
||||||
|
detectedPremiumAsns: trData.detectedPremiumAsns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Traceroute is optional, ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resolvedAddress, ipInfo, traceroute }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveResult(data: SaveResultRequest): Promise<SaveResultResponse> {
|
export async function saveResult(data: SaveResultRequest): Promise<SaveResultResponse> {
|
||||||
|
|||||||
@@ -368,6 +368,13 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compare-score-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.compare-ip-card {
|
.compare-ip-card {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -408,7 +415,8 @@
|
|||||||
margin: -0.5rem 0;
|
margin: -0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compare-ip-info-cards {
|
.compare-ip-info-cards,
|
||||||
|
.compare-score-cards {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
|
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { LatencyResult, TEST_NODES, IpInfo } from '@shared/types'
|
import { LatencyResult, TEST_NODES, IpInfo, TracerouteStats } from '@shared/types'
|
||||||
import ShareModal from './ShareModal'
|
import ShareModal from './ShareModal'
|
||||||
import ExpirationBanner from './ExpirationBanner'
|
import ExpirationBanner from './ExpirationBanner'
|
||||||
import ShareLinkCard from './ShareLinkCard'
|
import ShareLinkCard from './ShareLinkCard'
|
||||||
import IpInfoCard from './IpInfoCard'
|
import IpInfoCard from './IpInfoCard'
|
||||||
|
import ScoreCard from './ScoreCard'
|
||||||
import './ComparePage.css'
|
import './ComparePage.css'
|
||||||
|
|
||||||
const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/
|
const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/
|
||||||
@@ -36,6 +37,8 @@ export default function ComparePage() {
|
|||||||
const [resolvedIpB, setResolvedIpB] = useState<string | null>(null)
|
const [resolvedIpB, setResolvedIpB] = useState<string | null>(null)
|
||||||
const [ipInfoA, setIpInfoA] = useState<IpInfo | null>(null)
|
const [ipInfoA, setIpInfoA] = useState<IpInfo | null>(null)
|
||||||
const [ipInfoB, setIpInfoB] = useState<IpInfo | null>(null)
|
const [ipInfoB, setIpInfoB] = useState<IpInfo | null>(null)
|
||||||
|
const [tracerouteA, setTracerouteA] = useState<TracerouteStats | null>(null)
|
||||||
|
const [tracerouteB, setTracerouteB] = useState<TracerouteStats | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resultId) {
|
if (!resultId) {
|
||||||
@@ -50,6 +53,8 @@ export default function ComparePage() {
|
|||||||
setResolvedIpB(null)
|
setResolvedIpB(null)
|
||||||
setIpInfoA(null)
|
setIpInfoA(null)
|
||||||
setIpInfoB(null)
|
setIpInfoB(null)
|
||||||
|
setTracerouteA(null)
|
||||||
|
setTracerouteB(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +73,8 @@ export default function ComparePage() {
|
|||||||
setTargetB(input.rightTarget)
|
setTargetB(input.rightTarget)
|
||||||
|
|
||||||
const savedResults = data.results as {
|
const savedResults = data.results as {
|
||||||
left: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
left: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>
|
||||||
right: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
right: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftMap = new Map<string, LatencyResult>()
|
const leftMap = new Map<string, LatencyResult>()
|
||||||
@@ -79,7 +84,8 @@ export default function ComparePage() {
|
|||||||
leftMap.set(r.nodeId, {
|
leftMap.set(r.nodeId, {
|
||||||
nodeId: r.nodeId,
|
nodeId: r.nodeId,
|
||||||
latency: r.latency,
|
latency: r.latency,
|
||||||
status: r.success ? 'success' : 'failed'
|
status: r.success ? 'success' : 'failed',
|
||||||
|
stats: r.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +93,8 @@ export default function ComparePage() {
|
|||||||
rightMap.set(r.nodeId, {
|
rightMap.set(r.nodeId, {
|
||||||
nodeId: r.nodeId,
|
nodeId: r.nodeId,
|
||||||
latency: r.latency,
|
latency: r.latency,
|
||||||
status: r.success ? 'success' : 'failed'
|
status: r.success ? 'success' : 'failed',
|
||||||
|
stats: r.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +107,13 @@ export default function ComparePage() {
|
|||||||
if (savedIpInfo.left) setIpInfoA(savedIpInfo.left)
|
if (savedIpInfo.left) setIpInfoA(savedIpInfo.left)
|
||||||
if (savedIpInfo.right) setIpInfoB(savedIpInfo.right)
|
if (savedIpInfo.right) setIpInfoB(savedIpInfo.right)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load saved traceroute for readonly view
|
||||||
|
if (data.traceroute && 'left' in data.traceroute) {
|
||||||
|
const savedTraceroute = data.traceroute as { left: TracerouteStats | null; right: TracerouteStats | null }
|
||||||
|
if (savedTraceroute.left) setTracerouteA(savedTraceroute.left)
|
||||||
|
if (savedTraceroute.right) setTracerouteB(savedTraceroute.right)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setLoadError(t('无法加载测试结果', 'Failed to load test result'))
|
setLoadError(t('无法加载测试结果', 'Failed to load test result'))
|
||||||
@@ -132,9 +146,11 @@ export default function ComparePage() {
|
|||||||
setResolvedIpB(null)
|
setResolvedIpB(null)
|
||||||
setIpInfoA(null)
|
setIpInfoA(null)
|
||||||
setIpInfoB(null)
|
setIpInfoB(null)
|
||||||
|
setTracerouteA(null)
|
||||||
|
setTracerouteB(null)
|
||||||
|
|
||||||
const leftResults = new Map<string, { nodeId: string; latency: number | null; success: boolean }>()
|
const leftResults = new Map<string, { nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>()
|
||||||
const rightResults = new Map<string, { nodeId: string; latency: number | null; success: boolean }>()
|
const rightResults = new Map<string, { nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [resA, resB] = await Promise.all([
|
const [resA, resB] = await Promise.all([
|
||||||
@@ -144,7 +160,8 @@ export default function ComparePage() {
|
|||||||
leftResults.set(res.nodeId, {
|
leftResults.set(res.nodeId, {
|
||||||
nodeId: res.nodeId,
|
nodeId: res.nodeId,
|
||||||
latency: res.latency,
|
latency: res.latency,
|
||||||
success: res.status === 'success'
|
success: res.status === 'success',
|
||||||
|
stats: res.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -154,7 +171,8 @@ export default function ComparePage() {
|
|||||||
rightResults.set(res.nodeId, {
|
rightResults.set(res.nodeId, {
|
||||||
nodeId: res.nodeId,
|
nodeId: res.nodeId,
|
||||||
latency: res.latency,
|
latency: res.latency,
|
||||||
success: res.status === 'success'
|
success: res.status === 'success',
|
||||||
|
stats: res.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -173,6 +191,12 @@ export default function ComparePage() {
|
|||||||
if (resB.ipInfo) {
|
if (resB.ipInfo) {
|
||||||
setIpInfoB(resB.ipInfo)
|
setIpInfoB(resB.ipInfo)
|
||||||
}
|
}
|
||||||
|
if (resA.traceroute) {
|
||||||
|
setTracerouteA(resA.traceroute)
|
||||||
|
}
|
||||||
|
if (resB.traceroute) {
|
||||||
|
setTracerouteB(resB.traceroute)
|
||||||
|
}
|
||||||
|
|
||||||
const left = TEST_NODES.map(node => leftResults.get(node.id) ?? {
|
const left = TEST_NODES.map(node => leftResults.get(node.id) ?? {
|
||||||
nodeId: node.id, latency: null, success: false
|
nodeId: node.id, latency: null, success: false
|
||||||
@@ -186,7 +210,8 @@ export default function ComparePage() {
|
|||||||
type: 'compare',
|
type: 'compare',
|
||||||
input: { leftTarget: trimmedA, rightTarget: trimmedB },
|
input: { leftTarget: trimmedA, rightTarget: trimmedB },
|
||||||
results: { left, right },
|
results: { left, right },
|
||||||
ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null }
|
ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null },
|
||||||
|
traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null }
|
||||||
})
|
})
|
||||||
setShareUrl(url)
|
setShareUrl(url)
|
||||||
setShowShareModal(true)
|
setShowShareModal(true)
|
||||||
@@ -272,12 +297,14 @@ export default function ComparePage() {
|
|||||||
<span className="readonly-label">{t('目标 A', 'Target A')}</span>
|
<span className="readonly-label">{t('目标 A', 'Target A')}</span>
|
||||||
<span className="readonly-value">{targetA}</span>
|
<span className="readonly-value">{targetA}</span>
|
||||||
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
|
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
|
||||||
|
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
|
||||||
</div>
|
</div>
|
||||||
<div className="readonly-vs">VS</div>
|
<div className="readonly-vs">VS</div>
|
||||||
<div className="readonly-input-group">
|
<div className="readonly-input-group">
|
||||||
<span className="readonly-label">{t('目标 B', 'Target B')}</span>
|
<span className="readonly-label">{t('目标 B', 'Target B')}</span>
|
||||||
<span className="readonly-value">{targetB}</span>
|
<span className="readonly-value">{targetB}</span>
|
||||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
||||||
|
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
|
||||||
</div>
|
</div>
|
||||||
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,7 +352,7 @@ export default function ComparePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareUrl && !testing && (
|
{(shareUrl || resultsA.size > 0 || resultsB.size > 0) && !testing && (
|
||||||
<div className="compare-share-container">
|
<div className="compare-share-container">
|
||||||
{(ipInfoA || ipInfoB) && (
|
{(ipInfoA || ipInfoB) && (
|
||||||
<div className="compare-ip-info-cards">
|
<div className="compare-ip-info-cards">
|
||||||
@@ -333,7 +360,13 @@ export default function ComparePage() {
|
|||||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact className="compare-ip-card" />}
|
{ipInfoB && <IpInfoCard info={ipInfoB} compact className="compare-ip-card" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ShareLinkCard shareUrl={shareUrl} />
|
{(resultsA.size > 0 || resultsB.size > 0) && (
|
||||||
|
<div className="compare-score-cards">
|
||||||
|
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact className="compare-score-card" />}
|
||||||
|
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact className="compare-score-card" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import ShareModal from './ShareModal'
|
|||||||
import ExpirationBanner from './ExpirationBanner'
|
import ExpirationBanner from './ExpirationBanner'
|
||||||
import ShareLinkCard from './ShareLinkCard'
|
import ShareLinkCard from './ShareLinkCard'
|
||||||
import IpInfoCard from './IpInfoCard'
|
import IpInfoCard from './IpInfoCard'
|
||||||
|
import ScoreCard from './ScoreCard'
|
||||||
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
|
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
|
||||||
import { LatencyResult, IpInfo } from '@shared/types'
|
import { LatencyResult, IpInfo, TracerouteStats } from '@shared/types'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -25,6 +26,7 @@ export default function HomePage() {
|
|||||||
const [showShareModal, setShowShareModal] = useState(false)
|
const [showShareModal, setShowShareModal] = useState(false)
|
||||||
const [resolvedIp, setResolvedIp] = useState<string | null>(null)
|
const [resolvedIp, setResolvedIp] = useState<string | null>(null)
|
||||||
const [ipInfo, setIpInfo] = useState<IpInfo | null>(null)
|
const [ipInfo, setIpInfo] = useState<IpInfo | null>(null)
|
||||||
|
const [traceroute, setTraceroute] = useState<TracerouteStats | null>(null)
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
// Load saved result if viewing shared link
|
// Load saved result if viewing shared link
|
||||||
@@ -45,11 +47,12 @@ export default function HomePage() {
|
|||||||
setTarget(input.target)
|
setTarget(input.target)
|
||||||
|
|
||||||
const resultsMap = new Map<string, LatencyResult>()
|
const resultsMap = new Map<string, LatencyResult>()
|
||||||
for (const r of data.results as Array<{ nodeId: string; latency: number | null; success: boolean }>) {
|
for (const r of data.results as Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>) {
|
||||||
resultsMap.set(r.nodeId, {
|
resultsMap.set(r.nodeId, {
|
||||||
nodeId: r.nodeId,
|
nodeId: r.nodeId,
|
||||||
latency: r.latency,
|
latency: r.latency,
|
||||||
status: r.success ? 'success' : 'failed'
|
status: r.success ? 'success' : 'failed',
|
||||||
|
stats: r.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setResults(resultsMap)
|
setResults(resultsMap)
|
||||||
@@ -58,6 +61,11 @@ export default function HomePage() {
|
|||||||
if (data.ipInfo && 'ip' in data.ipInfo) {
|
if (data.ipInfo && 'ip' in data.ipInfo) {
|
||||||
setIpInfo(data.ipInfo as IpInfo)
|
setIpInfo(data.ipInfo as IpInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load saved traceroute for readonly view
|
||||||
|
if (data.traceroute && 'totalHops' in data.traceroute) {
|
||||||
|
setTraceroute(data.traceroute as TracerouteStats)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setError(t('无法加载测试结果', 'Failed to load test result'))
|
setError(t('无法加载测试结果', 'Failed to load test result'))
|
||||||
@@ -74,17 +82,19 @@ export default function HomePage() {
|
|||||||
setShareUrl(null)
|
setShareUrl(null)
|
||||||
setResolvedIp(null)
|
setResolvedIp(null)
|
||||||
setIpInfo(null)
|
setIpInfo(null)
|
||||||
|
setTraceroute(null)
|
||||||
setTarget(testTarget)
|
setTarget(testTarget)
|
||||||
|
|
||||||
const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean }> = []
|
const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }> = []
|
||||||
|
|
||||||
const { resolvedAddress, ipInfo: fetchedIpInfo } = await testAllNodes(testTarget, (result) => {
|
const { resolvedAddress, ipInfo: fetchedIpInfo, traceroute: fetchedTraceroute } = await testAllNodes(testTarget, (result) => {
|
||||||
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
||||||
if (result.status === 'success' || result.status === 'failed') {
|
if (result.status === 'success' || result.status === 'failed') {
|
||||||
finalResults.push({
|
finalResults.push({
|
||||||
nodeId: result.nodeId,
|
nodeId: result.nodeId,
|
||||||
latency: result.latency,
|
latency: result.latency,
|
||||||
success: result.status === 'success'
|
success: result.status === 'success',
|
||||||
|
stats: result.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -97,13 +107,18 @@ export default function HomePage() {
|
|||||||
setIpInfo(fetchedIpInfo)
|
setIpInfo(fetchedIpInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fetchedTraceroute) {
|
||||||
|
setTraceroute(fetchedTraceroute)
|
||||||
|
}
|
||||||
|
|
||||||
// Save result and get share URL
|
// Save result and get share URL
|
||||||
try {
|
try {
|
||||||
const { shareUrl: url } = await saveResult({
|
const { shareUrl: url } = await saveResult({
|
||||||
type: 'single',
|
type: 'single',
|
||||||
input: { target: testTarget },
|
input: { target: testTarget },
|
||||||
results: finalResults,
|
results: finalResults,
|
||||||
ipInfo: fetchedIpInfo
|
ipInfo: fetchedIpInfo,
|
||||||
|
traceroute: fetchedTraceroute
|
||||||
})
|
})
|
||||||
setShareUrl(url)
|
setShareUrl(url)
|
||||||
setShowShareModal(true)
|
setShowShareModal(true)
|
||||||
@@ -152,12 +167,13 @@ export default function HomePage() {
|
|||||||
<span className="readonly-value">{target}</span>
|
<span className="readonly-value">{target}</span>
|
||||||
</div>
|
</div>
|
||||||
{ipInfo && <IpInfoCard info={ipInfo} compact />}
|
{ipInfo && <IpInfoCard info={ipInfo} compact />}
|
||||||
|
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact />}
|
||||||
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IpInput onTest={handleTest} testing={testing} />
|
<IpInput onTest={handleTest} testing={testing} />
|
||||||
{(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl) && (
|
{(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl || results.size > 0) && (
|
||||||
<div className="test-result-info">
|
<div className="test-result-info">
|
||||||
{ipInfo ? (
|
{ipInfo ? (
|
||||||
<IpInfoCard info={ipInfo} compact />
|
<IpInfoCard info={ipInfo} compact />
|
||||||
@@ -167,6 +183,7 @@ export default function HomePage() {
|
|||||||
<span className="resolved-ip-value">{resolvedIp}</span>
|
<span className="resolved-ip-value">{resolvedIp}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact testing={testing} />}
|
||||||
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
47
src/client/components/ScoreBadges.tsx
Normal file
47
src/client/components/ScoreBadges.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
asns: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AsnConfig {
|
||||||
|
name: string
|
||||||
|
isp: 'ct' | 'cu' | 'cm'
|
||||||
|
tier: 'premium' | 'global'
|
||||||
|
label: string
|
||||||
|
labelEn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASN_CONFIG: Record<number, AsnConfig> = {
|
||||||
|
// China Telecom
|
||||||
|
4809: { name: 'CN2', isp: 'ct', tier: 'premium', label: '电信CN2精品网', labelEn: 'CT Next Carrying Network' },
|
||||||
|
23764: { name: 'CTG', isp: 'ct', tier: 'global', label: '电信国际出口', labelEn: 'CT Global Transit' },
|
||||||
|
// China Unicom
|
||||||
|
10099: { name: 'CUG', isp: 'cu', tier: 'global', label: '联通国际出口', labelEn: 'CU Global Transit' },
|
||||||
|
9929: { name: 'CUII', isp: 'cu', tier: 'premium', label: '联通精品网', labelEn: 'CU Industrial Internet' },
|
||||||
|
// China Mobile
|
||||||
|
58807: { name: 'CMIN2', isp: 'cm', tier: 'premium', label: '移动国际精品网', labelEn: 'CM Intl Premium' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IspBadges({ asns }: BadgeProps) {
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const uniqueAsns = Array.from(new Set(asns))
|
||||||
|
const badges = uniqueAsns.map(asn => ({ asn, config: ASN_CONFIG[asn] })).filter(b => b.config)
|
||||||
|
|
||||||
|
if (badges.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="isp-badges">
|
||||||
|
{badges.map(({ asn, config }) => (
|
||||||
|
<span
|
||||||
|
key={asn}
|
||||||
|
className={`isp-badge badge-${config.isp} badge-${config.tier}`}
|
||||||
|
title={language === 'en' ? config.labelEn : config.label}
|
||||||
|
>
|
||||||
|
<span className="badge-dot" />
|
||||||
|
<span className="badge-name">{config.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
381
src/client/components/ScoreCard.css
Normal file
381
src/client/components/ScoreCard.css
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
.score-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px var(--shadow-color);
|
||||||
|
transition: transform var(--transition-smooth), border-color var(--transition-smooth), box-shadow var(--transition-smooth);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 10px 25px -5px var(--shadow-color), var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-value {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-max {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-grade {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grade Colors */
|
||||||
|
.grade-excellent { color: #059669; background: rgba(5, 150, 105, 0.15); }
|
||||||
|
.grade-great { color: #10b981; background: rgba(16, 185, 129, 0.15); }
|
||||||
|
.grade-good { color: #34d399; background: rgba(52, 211, 153, 0.15); }
|
||||||
|
.grade-fair { color: #facc15; background: rgba(250, 204, 21, 0.15); }
|
||||||
|
.grade-poor { color: #ef4444; background: rgba(239, 68, 68, 0.15); }
|
||||||
|
|
||||||
|
/* Score Value Colors */
|
||||||
|
.score-excellent { color: #059669; }
|
||||||
|
.score-great { color: #10b981; }
|
||||||
|
.score-good { color: #34d399; }
|
||||||
|
.score-fair { color: #facc15; }
|
||||||
|
.score-poor { color: #ef4444; }
|
||||||
|
|
||||||
|
/* Dimensions Layout */
|
||||||
|
.score-dimensions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-dimension {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-score {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-max {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.dim-progress-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar Colors */
|
||||||
|
.bg-excellent { background-color: #059669; }
|
||||||
|
.bg-great { background-color: #10b981; }
|
||||||
|
.bg-good { background-color: #34d399; }
|
||||||
|
.bg-fair { background-color: #facc15; }
|
||||||
|
.bg-poor { background-color: #ef4444; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Stats Footer */
|
||||||
|
.score-footer {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-stat strong {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact Mode */
|
||||||
|
.score-card.compact {
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card.compact .score-header {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card.compact .score-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card.compact .score-grade {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card.compact .score-dimensions {
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card.compact .dim-header {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card.compact .dim-progress-bg {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ISP Badges */
|
||||||
|
.isp-badges {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.isp-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: help;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.isp-badge:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-name {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* China Telecom - Green theme */
|
||||||
|
.badge-ct {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: #34d399;
|
||||||
|
border-color: rgba(52, 211, 153, 0.25);
|
||||||
|
}
|
||||||
|
.badge-ct.badge-premium {
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
border-color: rgba(52, 211, 153, 0.4);
|
||||||
|
box-shadow: 0 0 6px rgba(52, 211, 153, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* China Unicom - Red theme */
|
||||||
|
.badge-cu {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #f87171;
|
||||||
|
border-color: rgba(248, 113, 113, 0.25);
|
||||||
|
}
|
||||||
|
.badge-cu.badge-premium {
|
||||||
|
background: rgba(239, 68, 68, 0.18);
|
||||||
|
border-color: rgba(248, 113, 113, 0.4);
|
||||||
|
box-shadow: 0 0 6px rgba(248, 113, 113, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* China Mobile - Blue theme */
|
||||||
|
.badge-cm {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: #60a5fa;
|
||||||
|
border-color: rgba(96, 165, 250, 0.25);
|
||||||
|
}
|
||||||
|
.badge-cm.badge-premium {
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
box-shadow: 0 0 6px rgba(96, 165, 250, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dimension Details (expandable) */
|
||||||
|
.score-dimension {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
padding: 0.3rem;
|
||||||
|
margin: -0.3rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-dimension:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-reason {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
background: var(--bg-secondary, var(--hover-bg));
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-toggle {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-toggle.open {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-details {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim-details.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.pos {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.detail-item.pos .detail-icon::before {
|
||||||
|
content: '✓';
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.warn {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.detail-item.warn .detail-icon::before {
|
||||||
|
content: '!';
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.neg {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.detail-item.neg .detail-icon::before {
|
||||||
|
content: '✕';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.score-loading {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.score-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-footer {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/client/components/ScoreCard.tsx
Normal file
164
src/client/components/ScoreCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { LatencyResult, IpInfo, TracerouteStats, ScoreReason } from '@shared/types'
|
||||||
|
import { calculateDetailedScore } from '../utils/scoring'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { IspBadges } from './ScoreBadges'
|
||||||
|
import './ScoreCard.css'
|
||||||
|
|
||||||
|
interface ScoreCardProps {
|
||||||
|
results: LatencyResult[]
|
||||||
|
ipInfo?: IpInfo | null
|
||||||
|
traceroute?: TracerouteStats | null
|
||||||
|
compact?: boolean
|
||||||
|
className?: string
|
||||||
|
testing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScoreCard({ results, ipInfo = null, traceroute = null, compact = false, className = '', testing = false }: ScoreCardProps) {
|
||||||
|
const { t, language } = useLanguage()
|
||||||
|
const [expandedDims, setExpandedDims] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const scoreResult = useMemo(() => calculateDetailedScore(results, ipInfo, traceroute), [results, ipInfo, traceroute])
|
||||||
|
|
||||||
|
// Hide during testing to avoid showing incomplete scores
|
||||||
|
if (testing || !scoreResult) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalScore, grade, level, transmission, quality, balance, routing, stats } = scoreResult
|
||||||
|
|
||||||
|
const toggleDim = (dim: string) => {
|
||||||
|
setExpandedDims(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(dim)) next.delete(dim)
|
||||||
|
else next.add(dim)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSummary = (reasons: ScoreReason[]): string => {
|
||||||
|
const first = reasons[0]
|
||||||
|
if (!first) return ''
|
||||||
|
return language === 'en' ? first.messageEn : first.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDetails = (dimKey: string, reasons: ScoreReason[]) => {
|
||||||
|
const isExpanded = expandedDims.has(dimKey)
|
||||||
|
if (reasons.length <= 1) return null
|
||||||
|
return (
|
||||||
|
<div className={`dim-details ${isExpanded ? 'visible' : ''}`} style={{ height: isExpanded ? 'auto' : 0 }}>
|
||||||
|
{reasons.slice(1).map((r, i) => (
|
||||||
|
<div key={i} className={`detail-item ${r.type === 'positive' ? 'pos' : r.type === 'negative' ? 'neg' : 'warn'}`}>
|
||||||
|
<span className="detail-icon" />
|
||||||
|
<span>{language === 'en' ? r.messageEn : r.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`score-card ${compact ? 'compact' : ''} ${className}`}>
|
||||||
|
<div className="score-header">
|
||||||
|
<div className="score-left">
|
||||||
|
<span className="score-title">{t('网络质量评分', 'Network Quality Score')}</span>
|
||||||
|
<div className="score-display">
|
||||||
|
<span className={`score-value score-${level}`}>{totalScore}</span>
|
||||||
|
<span className="score-max">/ 100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`score-grade grade-${level}`}>{grade}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="score-dimensions">
|
||||||
|
<div className={`score-dimension ${expandedDims.has('transmission') ? 'expanded' : ''}`} onClick={() => toggleDim('transmission')}>
|
||||||
|
<div className="dim-header">
|
||||||
|
<span className="dim-label">{t('传输效率', 'Transmission')}</span>
|
||||||
|
<span className="dim-score">
|
||||||
|
<span className={`score-${level}`}>{transmission.score}</span>
|
||||||
|
<span className="dim-max">/40</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dim-progress-bg">
|
||||||
|
<div className={`dim-progress-fill bg-${level}`} style={{ width: `${(transmission.score / 40) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="dim-reason">
|
||||||
|
<span>{getSummary(transmission.reasons)}</span>
|
||||||
|
{transmission.reasons.length > 1 && <span className={`dim-toggle ${expandedDims.has('transmission') ? 'open' : ''}`}>›</span>}
|
||||||
|
</div>
|
||||||
|
{renderDetails('transmission', transmission.reasons)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`score-dimension ${expandedDims.has('quality') ? 'expanded' : ''}`} onClick={() => toggleDim('quality')}>
|
||||||
|
<div className="dim-header">
|
||||||
|
<span className="dim-label">{t('链路质量', 'Link Quality')}</span>
|
||||||
|
<span className="dim-score">
|
||||||
|
<span className={`score-${level}`}>{quality.score}</span>
|
||||||
|
<span className="dim-max">/30</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dim-progress-bg">
|
||||||
|
<div className={`dim-progress-fill bg-${level}`} style={{ width: `${(quality.score / 30) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="dim-reason">
|
||||||
|
<span>{getSummary(quality.reasons)}</span>
|
||||||
|
{quality.reasons.length > 1 && <span className={`dim-toggle ${expandedDims.has('quality') ? 'open' : ''}`}>›</span>}
|
||||||
|
</div>
|
||||||
|
{renderDetails('quality', quality.reasons)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`score-dimension ${expandedDims.has('balance') ? 'expanded' : ''}`} onClick={() => toggleDim('balance')}>
|
||||||
|
<div className="dim-header">
|
||||||
|
<span className="dim-label">{t('全球均衡', 'Global Balance')}</span>
|
||||||
|
<span className="dim-score">
|
||||||
|
<span className={`score-${level}`}>{balance.score}</span>
|
||||||
|
<span className="dim-max">/20</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dim-progress-bg">
|
||||||
|
<div className={`dim-progress-fill bg-${level}`} style={{ width: `${(balance.score / 20) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="dim-reason">
|
||||||
|
<span>{getSummary(balance.reasons)}</span>
|
||||||
|
{balance.reasons.length > 1 && <span className={`dim-toggle ${expandedDims.has('balance') ? 'open' : ''}`}>›</span>}
|
||||||
|
</div>
|
||||||
|
{renderDetails('balance', balance.reasons)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`score-dimension ${expandedDims.has('routing') ? 'expanded' : ''}`} onClick={() => toggleDim('routing')}>
|
||||||
|
<div className="dim-header">
|
||||||
|
<span className="dim-label">
|
||||||
|
{t('路由质量', 'Routing Health')}
|
||||||
|
{traceroute?.detectedPremiumAsns && <IspBadges asns={traceroute.detectedPremiumAsns} />}
|
||||||
|
</span>
|
||||||
|
<span className="dim-score">
|
||||||
|
<span className={`score-${level}`}>{routing.score}</span>
|
||||||
|
<span className="dim-max">/10</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dim-progress-bg">
|
||||||
|
<div className={`dim-progress-fill bg-${level}`} style={{ width: `${(routing.score / 10) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="dim-reason">
|
||||||
|
<span>{getSummary(routing.reasons)}</span>
|
||||||
|
{routing.reasons.length > 1 && <span className={`dim-toggle ${expandedDims.has('routing') ? 'open' : ''}`}>›</span>}
|
||||||
|
</div>
|
||||||
|
{renderDetails('routing', routing.reasons)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!compact && (
|
||||||
|
<div className="score-footer">
|
||||||
|
<div className="score-stat">
|
||||||
|
<span>{t('成功', 'Success')}:</span>
|
||||||
|
<strong>{stats.success}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="score-stat">
|
||||||
|
<span>{t('平均延迟', 'Avg Latency')}:</span>
|
||||||
|
<strong>{stats.avgLatency ?? '-'}ms</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/client/utils/geo.ts
Normal file
21
src/client/utils/geo.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Calculates the Great Circle distance between two coordinates using the Haversine formula.
|
||||||
|
* @returns Distance in kilometers
|
||||||
|
*/
|
||||||
|
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371 // Earth's radius in km
|
||||||
|
const dLat = toRad(lat2 - lat1)
|
||||||
|
const dLon = toRad(lon2 - lon1)
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
||||||
|
Math.sin(dLon / 2) * Math.sin(dLon / 2)
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
return R * c
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRad(degrees: number): number {
|
||||||
|
return degrees * (Math.PI / 180)
|
||||||
|
}
|
||||||
587
src/client/utils/scoring.ts
Normal file
587
src/client/utils/scoring.ts
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
import { LatencyResult, IpInfo, TEST_NODES, TracerouteStats, ScoreReason } from '@shared/types'
|
||||||
|
import { calculateDistance } from './geo'
|
||||||
|
|
||||||
|
const SPEED_OF_LIGHT_FIBER = 200 // km/ms (approx 200,000 km/s in fiber)
|
||||||
|
|
||||||
|
export interface TransmissionScore {
|
||||||
|
score: number // 0-40 points
|
||||||
|
effectiveFei: number // Weighted by availability (0-100%)
|
||||||
|
rawFei: number // Average FEI of successful nodes (0-100%)
|
||||||
|
reasons: ScoreReason[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualityScore {
|
||||||
|
score: number // 0-30 points
|
||||||
|
lossScore: number // 0-20 points (packet loss)
|
||||||
|
jitterScore: number // 0-10 points (jitter)
|
||||||
|
avgLoss: number // Average packet loss %
|
||||||
|
avgJitter: number // Average jitter % of latency
|
||||||
|
reasons: ScoreReason[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContinentStats {
|
||||||
|
fei: number
|
||||||
|
loss: number
|
||||||
|
reachable: boolean
|
||||||
|
nodeCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceScore {
|
||||||
|
score: number // 0-20 points
|
||||||
|
variance: number // FEI variance as percentage
|
||||||
|
fullCoverage: boolean // All 5 continents have loss < 3%
|
||||||
|
lameCount: number // Continents unreachable or >500ms
|
||||||
|
continentStats: Record<string, ContinentStats>
|
||||||
|
reasons: ScoreReason[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingScore {
|
||||||
|
score: number // 0-10 points
|
||||||
|
totalHops: number
|
||||||
|
hasOptimizedRoute: boolean
|
||||||
|
hopPenalty: number // Points deducted for high hop count
|
||||||
|
premiumBonus: number // Bonus points for premium network
|
||||||
|
reasons: ScoreReason[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoringResult {
|
||||||
|
totalScore: number
|
||||||
|
grade: string
|
||||||
|
level: 'excellent' | 'great' | 'good' | 'fair' | 'poor'
|
||||||
|
transmission: TransmissionScore
|
||||||
|
quality: QualityScore
|
||||||
|
balance: BalanceScore
|
||||||
|
routing: RoutingScore
|
||||||
|
stats: {
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
total: number
|
||||||
|
avgLatency: number | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the Fiber Efficiency Index (FEI) for a single link
|
||||||
|
* FEI = Theoretical_RTT / Actual_RTT * 100%
|
||||||
|
*/
|
||||||
|
function calculateLinkFei(distanceKm: number, actualLatencyMs: number): number {
|
||||||
|
if (actualLatencyMs <= 0) return 0
|
||||||
|
const theoreticalRtt = (distanceKm * 2) / SPEED_OF_LIGHT_FIBER
|
||||||
|
return Math.min(100, (theoreticalRtt / actualLatencyMs) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Effective FEI percentage to a score of 0-40 points
|
||||||
|
*/
|
||||||
|
function mapFeiToPoints(fei: number): number {
|
||||||
|
if (fei > 60) return 40
|
||||||
|
if (fei > 40) return 25 + ((fei - 40) / 20) * 10
|
||||||
|
if (fei > 20) return 10 + ((fei - 20) / 20) * 10
|
||||||
|
return (fei / 20) * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps packet loss percentage to score (0-20 points)
|
||||||
|
*/
|
||||||
|
function mapLossToPoints(loss: number): number {
|
||||||
|
if (loss === 0) return 20
|
||||||
|
if (loss <= 1) return 15
|
||||||
|
if (loss <= 5) return 5
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps jitter percentage to score (0-10 points)
|
||||||
|
* Jitter = (SD / avg) * 100, where SD ≈ (max - min) / 2
|
||||||
|
*/
|
||||||
|
function mapJitterToPoints(jitterPct: number): number {
|
||||||
|
if (jitterPct < 10) return 10
|
||||||
|
if (jitterPct <= 30) return 5
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates transmission efficiency score based on FEI with availability weighting.
|
||||||
|
*/
|
||||||
|
export function calculateTransmissionScore(
|
||||||
|
results: LatencyResult[],
|
||||||
|
ipInfo?: IpInfo | null
|
||||||
|
): TransmissionScore {
|
||||||
|
const completed = results.filter(r => r.status === 'success' || r.status === 'failed')
|
||||||
|
const successNodes = completed.filter(r => r.status === 'success')
|
||||||
|
const totalCount = completed.length
|
||||||
|
const successCount = successNodes.length
|
||||||
|
|
||||||
|
const lat = ipInfo?.lat
|
||||||
|
const lon = ipInfo?.lon
|
||||||
|
const hasValidCoords = Number.isFinite(lat) && Number.isFinite(lon)
|
||||||
|
if (totalCount === 0) {
|
||||||
|
return { score: 0, effectiveFei: 0, rawFei: 0, reasons: [] }
|
||||||
|
}
|
||||||
|
if (!hasValidCoords) {
|
||||||
|
return {
|
||||||
|
score: 0,
|
||||||
|
effectiveFei: 0,
|
||||||
|
rawFei: 0,
|
||||||
|
reasons: [{ type: 'warning', message: '缺少地理位置,无法计算传输效率', messageEn: 'Missing geolocation, cannot compute efficiency' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalFei = 0
|
||||||
|
let feiNodeCount = 0
|
||||||
|
|
||||||
|
for (const res of successNodes) {
|
||||||
|
const node = TEST_NODES.find(n => n.id === res.nodeId)
|
||||||
|
const latencyMs = res.latency
|
||||||
|
if (node && latencyMs !== null && Number.isFinite(latencyMs)) {
|
||||||
|
const dist = calculateDistance(lat as number, lon as number, node.coords[1], node.coords[0])
|
||||||
|
totalFei += calculateLinkFei(dist, latencyMs)
|
||||||
|
feiNodeCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFei = feiNodeCount > 0 ? totalFei / feiNodeCount : 0
|
||||||
|
const availabilityFactor = successCount / totalCount
|
||||||
|
const effectiveFei = rawFei * availabilityFactor
|
||||||
|
const score = mapFeiToPoints(effectiveFei)
|
||||||
|
|
||||||
|
const reasons: ScoreReason[] = []
|
||||||
|
// FEI efficiency analysis
|
||||||
|
if (effectiveFei >= 60) {
|
||||||
|
reasons.push({ type: 'positive', message: '传输效率极高,接近光纤理论速度', messageEn: 'Excellent efficiency, near fiber-optic speed' })
|
||||||
|
} else if (effectiveFei >= 40) {
|
||||||
|
reasons.push({ type: 'positive', message: '传输效率良好,路由较直接', messageEn: 'Good efficiency, direct routing' })
|
||||||
|
} else if (effectiveFei >= 20) {
|
||||||
|
reasons.push({ type: 'warning', message: '传输效率一般,可能存在绕路', messageEn: 'Moderate efficiency, possible detours' })
|
||||||
|
} else {
|
||||||
|
reasons.push({ type: 'negative', message: '传输效率较低,网络绕路严重', messageEn: 'Low efficiency, significant routing detours' })
|
||||||
|
}
|
||||||
|
// Availability analysis
|
||||||
|
const failedCount = totalCount - successCount
|
||||||
|
if (availabilityFactor === 1) {
|
||||||
|
reasons.push({ type: 'positive', message: '所有节点均可达', messageEn: 'All nodes reachable' })
|
||||||
|
} else if (failedCount > 0) {
|
||||||
|
reasons.push({ type: 'negative', message: `${failedCount}个节点连接失败,影响可用性`, messageEn: `${failedCount} nodes failed, affecting availability` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: Math.round(score),
|
||||||
|
effectiveFei: Math.round(effectiveFei),
|
||||||
|
rawFei: Math.round(rawFei),
|
||||||
|
reasons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates Link Quality Score (0-30 points) based on Packet Loss and Jitter.
|
||||||
|
* Uses "Average of Scores" strategy to prevent outliers from skewing the result.
|
||||||
|
*/
|
||||||
|
export function calculateLinkQualityScore(results: LatencyResult[]): QualityScore {
|
||||||
|
const completed = results.filter(r => r.status === 'success' || r.status === 'failed')
|
||||||
|
if (completed.length === 0) {
|
||||||
|
return { score: 0, lossScore: 0, jitterScore: 0, avgLoss: 0, avgJitter: 0, reasons: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalLossPoints = 0
|
||||||
|
let totalJitterPoints = 0
|
||||||
|
let totalLoss = 0
|
||||||
|
let totalJitter = 0
|
||||||
|
let validNodes = 0
|
||||||
|
|
||||||
|
for (const res of completed) {
|
||||||
|
if (res.status === 'failed' || !res.latency || !res.stats) {
|
||||||
|
totalLossPoints += 0
|
||||||
|
totalJitterPoints += 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const loss = res.stats.loss || 0
|
||||||
|
totalLossPoints += mapLossToPoints(loss)
|
||||||
|
totalLoss += loss
|
||||||
|
|
||||||
|
const { min, max } = res.stats
|
||||||
|
const sdApprox = (max - min) / 2
|
||||||
|
const jitterPct = res.latency > 0 ? (sdApprox / res.latency) * 100 : 0
|
||||||
|
totalJitterPoints += mapJitterToPoints(jitterPct)
|
||||||
|
totalJitter += jitterPct
|
||||||
|
validNodes++
|
||||||
|
}
|
||||||
|
|
||||||
|
const lossScore = Math.round(totalLossPoints / completed.length)
|
||||||
|
const jitterScore = Math.round(totalJitterPoints / completed.length)
|
||||||
|
const avgLoss = validNodes > 0 ? Math.round(totalLoss / validNodes * 10) / 10 : 0
|
||||||
|
const avgJitter = validNodes > 0 ? Math.round(totalJitter / validNodes) : 0
|
||||||
|
|
||||||
|
if (validNodes === 0) {
|
||||||
|
return {
|
||||||
|
score: lossScore + jitterScore,
|
||||||
|
lossScore,
|
||||||
|
jitterScore,
|
||||||
|
avgLoss,
|
||||||
|
avgJitter,
|
||||||
|
reasons: [{ type: 'warning', message: '缺少有效链路质量数据', messageEn: 'No valid link quality samples yet' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasons: ScoreReason[] = []
|
||||||
|
// Packet loss analysis
|
||||||
|
if (avgLoss === 0) {
|
||||||
|
reasons.push({ type: 'positive', message: '链路稳定,无丢包', messageEn: 'Stable link, no packet loss' })
|
||||||
|
} else if (avgLoss <= 1) {
|
||||||
|
reasons.push({ type: 'warning', message: `轻微丢包 ${avgLoss}%,基本不影响体验`, messageEn: `Minor packet loss ${avgLoss}%, minimal impact` })
|
||||||
|
} else if (avgLoss <= 5) {
|
||||||
|
reasons.push({ type: 'negative', message: `丢包率 ${avgLoss}%,影响实时应用`, messageEn: `${avgLoss}% loss, affects real-time apps` })
|
||||||
|
} else {
|
||||||
|
reasons.push({ type: 'negative', message: `严重丢包 ${avgLoss}%,网络不稳定`, messageEn: `Severe ${avgLoss}% loss, unstable network` })
|
||||||
|
}
|
||||||
|
// Jitter analysis
|
||||||
|
if (avgJitter < 10) {
|
||||||
|
reasons.push({ type: 'positive', message: '网络抖动极低,延迟稳定', messageEn: 'Very low jitter, stable latency' })
|
||||||
|
} else if (avgJitter <= 30) {
|
||||||
|
reasons.push({ type: 'warning', message: `抖动 ${avgJitter}%,延迟波动一般`, messageEn: `${avgJitter}% jitter, moderate fluctuation` })
|
||||||
|
} else {
|
||||||
|
reasons.push({ type: 'negative', message: `抖动 ${avgJitter}%,延迟波动较大`, messageEn: `${avgJitter}% jitter, high fluctuation` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: lossScore + jitterScore,
|
||||||
|
lossScore,
|
||||||
|
jitterScore,
|
||||||
|
avgLoss,
|
||||||
|
avgJitter,
|
||||||
|
reasons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map 7 regions to 5 continents
|
||||||
|
const REGION_TO_CONTINENT: Record<string, string> = {
|
||||||
|
'North America': 'Americas',
|
||||||
|
'South America': 'Americas',
|
||||||
|
'Asia': 'Asia',
|
||||||
|
'Middle East': 'Asia',
|
||||||
|
'Europe': 'Europe',
|
||||||
|
'Africa': 'Africa',
|
||||||
|
'Oceania': 'Oceania'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTINENTS = ['Americas', 'Asia', 'Europe', 'Africa', 'Oceania'] as const
|
||||||
|
|
||||||
|
const SPEED_OF_LIGHT_FIBER_BALANCE = 200 // km/ms
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates FEI for balance scoring (same formula as transmission)
|
||||||
|
*/
|
||||||
|
function calculateBalanceFei(distanceKm: number, latencyMs: number): number {
|
||||||
|
if (latencyMs <= 0) return 0
|
||||||
|
const theoreticalRtt = (distanceKm * 2) / SPEED_OF_LIGHT_FIBER_BALANCE
|
||||||
|
return Math.min(100, (theoreticalRtt / latencyMs) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps FEI variance to score (0-20 points)
|
||||||
|
* Lower variance = higher score (more globally balanced)
|
||||||
|
*/
|
||||||
|
function mapVarianceToPoints(variance: number): number {
|
||||||
|
if (variance <= 10) return 18 + ((10 - variance) / 10) * 2
|
||||||
|
if (variance <= 30) return 12 + ((30 - variance) / 20) * 6
|
||||||
|
if (variance <= 50) return 6 + ((50 - variance) / 20) * 6
|
||||||
|
return Math.max(0, 6 - ((variance - 50) / 50) * 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates Global Balance Score (0-20 points)
|
||||||
|
* Measures consistency of network quality across 5 continents.
|
||||||
|
*/
|
||||||
|
export function calculateGlobalBalanceScore(
|
||||||
|
results: LatencyResult[],
|
||||||
|
ipInfo?: IpInfo | null
|
||||||
|
): BalanceScore {
|
||||||
|
const emptyResult: BalanceScore = {
|
||||||
|
score: 0,
|
||||||
|
variance: 0,
|
||||||
|
fullCoverage: false,
|
||||||
|
lameCount: 5,
|
||||||
|
continentStats: {},
|
||||||
|
reasons: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = ipInfo?.lat
|
||||||
|
const lon = ipInfo?.lon
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||||
|
return {
|
||||||
|
...emptyResult,
|
||||||
|
reasons: [{ type: 'warning', message: '缺少地理位置,无法计算全球均衡', messageEn: 'Missing geolocation, cannot compute balance' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group results by continent
|
||||||
|
const continentData: Record<string, { feis: number[], losses: number[], latencies: number[], total: number, success: number }> = {}
|
||||||
|
for (const c of CONTINENTS) {
|
||||||
|
continentData[c] = { feis: [], losses: [], latencies: [], total: 0, success: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const res of results) {
|
||||||
|
if (res.status !== 'success' && res.status !== 'failed') continue
|
||||||
|
|
||||||
|
const node = TEST_NODES.find(n => n.id === res.nodeId)
|
||||||
|
if (!node) continue
|
||||||
|
|
||||||
|
const continent = REGION_TO_CONTINENT[node.region]
|
||||||
|
if (!continent || !continentData[continent]) continue
|
||||||
|
|
||||||
|
continentData[continent].total++
|
||||||
|
|
||||||
|
if (res.status === 'success' && res.latency !== null) {
|
||||||
|
continentData[continent].success++
|
||||||
|
continentData[continent].latencies.push(res.latency)
|
||||||
|
|
||||||
|
const dist = calculateDistance(lat as number, lon as number, node.coords[1], node.coords[0])
|
||||||
|
const fei = calculateBalanceFei(dist, res.latency)
|
||||||
|
continentData[continent].feis.push(fei)
|
||||||
|
|
||||||
|
if (res.stats) {
|
||||||
|
continentData[continent].losses.push(res.stats.loss || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate per-continent stats
|
||||||
|
const continentStats: Record<string, ContinentStats> = {}
|
||||||
|
const validFeis: number[] = []
|
||||||
|
let lameCount = 0
|
||||||
|
let fullCoverageCheck = true
|
||||||
|
|
||||||
|
for (const continent of CONTINENTS) {
|
||||||
|
const data = continentData[continent]
|
||||||
|
|
||||||
|
if (data.total === 0) {
|
||||||
|
// No nodes tested for this continent
|
||||||
|
continentStats[continent] = { fei: 0, loss: 0, reachable: false, nodeCount: 0 }
|
||||||
|
lameCount++
|
||||||
|
fullCoverageCheck = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgLatency = data.latencies.length > 0
|
||||||
|
? data.latencies.reduce((a, b) => a + b, 0) / data.latencies.length
|
||||||
|
: Infinity
|
||||||
|
|
||||||
|
const avgFei = data.feis.length > 0
|
||||||
|
? data.feis.reduce((a, b) => a + b, 0) / data.feis.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const avgLoss = data.losses.length > 0
|
||||||
|
? data.losses.reduce((a, b) => a + b, 0) / data.losses.length
|
||||||
|
: 100
|
||||||
|
|
||||||
|
// "Lame" if all failed or avg latency > 500ms
|
||||||
|
const isLame = data.success === 0 || avgLatency > 500
|
||||||
|
const reachable = !isLame
|
||||||
|
|
||||||
|
continentStats[continent] = {
|
||||||
|
fei: Math.round(avgFei),
|
||||||
|
loss: Math.round(avgLoss * 10) / 10,
|
||||||
|
reachable,
|
||||||
|
nodeCount: data.total
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLame) {
|
||||||
|
lameCount++
|
||||||
|
fullCoverageCheck = false
|
||||||
|
} else {
|
||||||
|
validFeis.push(avgFei)
|
||||||
|
if (avgLoss >= 3) {
|
||||||
|
fullCoverageCheck = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply scoring rules
|
||||||
|
// Rule 1: Lame penalty - more than 2 continents unreachable = 0 points
|
||||||
|
if (lameCount > 2) {
|
||||||
|
return {
|
||||||
|
score: 0,
|
||||||
|
variance: 0,
|
||||||
|
fullCoverage: false,
|
||||||
|
lameCount,
|
||||||
|
continentStats,
|
||||||
|
reasons: [{ type: 'negative', message: `${lameCount}个区域无法连接,覆盖严重不足`, messageEn: `${lameCount} regions unreachable, poor coverage` }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2: Full coverage bonus - all 5 continents have loss < 3%
|
||||||
|
if (fullCoverageCheck && lameCount === 0) {
|
||||||
|
return {
|
||||||
|
score: 20,
|
||||||
|
variance: 0,
|
||||||
|
fullCoverage: true,
|
||||||
|
lameCount: 0,
|
||||||
|
continentStats,
|
||||||
|
reasons: [
|
||||||
|
{ type: 'positive', message: '全球5大区域覆盖完整', messageEn: 'Full coverage across 5 regions' },
|
||||||
|
{ type: 'positive', message: '各区域丢包率均低于3%', messageEn: 'All regions under 3% packet loss' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 3: Calculate FEI variance across reachable continents
|
||||||
|
let variance = 0
|
||||||
|
if (validFeis.length >= 2) {
|
||||||
|
const mean = validFeis.reduce((a, b) => a + b, 0) / validFeis.length
|
||||||
|
const squaredDiffs = validFeis.map(f => Math.pow(f - mean, 2))
|
||||||
|
const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / validFeis.length
|
||||||
|
const stdDev = Math.sqrt(avgSquaredDiff)
|
||||||
|
// Coefficient of variation (CV) as percentage
|
||||||
|
variance = mean > 0 ? (stdDev / mean) * 100 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = Math.round(mapVarianceToPoints(variance))
|
||||||
|
|
||||||
|
const reasons: ScoreReason[] = []
|
||||||
|
if (lameCount > 0) {
|
||||||
|
reasons.push({ type: 'negative', message: `${lameCount}个区域无法连接或延迟过高`, messageEn: `${lameCount} regions unreachable or slow` })
|
||||||
|
}
|
||||||
|
if (variance <= 20) {
|
||||||
|
reasons.push({ type: 'positive', message: '各区域延迟表现均衡', messageEn: 'Balanced latency across regions' })
|
||||||
|
} else if (variance > 50) {
|
||||||
|
reasons.push({ type: 'negative', message: `区域间差异显著 (${Math.round(variance)}%)`, messageEn: `High regional variance (${Math.round(variance)}%)` })
|
||||||
|
} else {
|
||||||
|
reasons.push({ type: 'warning', message: `区域间存在一定差异 (${Math.round(variance)}%)`, messageEn: `Moderate regional variance (${Math.round(variance)}%)` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
variance: Math.round(variance),
|
||||||
|
fullCoverage: false,
|
||||||
|
lameCount,
|
||||||
|
continentStats,
|
||||||
|
reasons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates Routing Health Score (0-10 points)
|
||||||
|
* Based on AS hop count and premium network detection.
|
||||||
|
*
|
||||||
|
* Scoring:
|
||||||
|
* - Base: 6 points
|
||||||
|
* - Hop penalty: -3 points if hops > 25, -1 if hops > 20
|
||||||
|
* - Premium bonus: +4 points for optimized route (CN2, CMIN2, etc.)
|
||||||
|
* - Max: 10 points
|
||||||
|
*/
|
||||||
|
export function calculateRoutingHealthScore(
|
||||||
|
traceroute?: TracerouteStats | null
|
||||||
|
): RoutingScore {
|
||||||
|
const emptyResult: RoutingScore = {
|
||||||
|
score: 5,
|
||||||
|
totalHops: 0,
|
||||||
|
hasOptimizedRoute: false,
|
||||||
|
hopPenalty: 0,
|
||||||
|
premiumBonus: 0,
|
||||||
|
reasons: [{ type: 'warning', message: '路由信息检测中', messageEn: 'Detecting routing info' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!traceroute) {
|
||||||
|
return emptyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalHops, hasOptimizedRoute } = traceroute
|
||||||
|
|
||||||
|
// Calculate hop penalty
|
||||||
|
let hopPenalty = 0
|
||||||
|
if (totalHops > 25) {
|
||||||
|
hopPenalty = 3
|
||||||
|
} else if (totalHops > 20) {
|
||||||
|
hopPenalty = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premium network bonus
|
||||||
|
const premiumBonus = hasOptimizedRoute ? 4 : 0
|
||||||
|
|
||||||
|
// Base 6 points, minus penalty, plus bonus, capped at 10
|
||||||
|
const rawScore = 6 - hopPenalty + premiumBonus
|
||||||
|
const score = Math.max(0, Math.min(10, rawScore))
|
||||||
|
|
||||||
|
const reasons: ScoreReason[] = []
|
||||||
|
// Premium route detection
|
||||||
|
if (hasOptimizedRoute) {
|
||||||
|
reasons.push({ type: 'positive', message: '检测到优质线路(CN2/CMIN2等)', messageEn: 'Premium route detected (CN2/CMIN2)' })
|
||||||
|
}
|
||||||
|
// Hop count analysis
|
||||||
|
if (totalHops > 0 && totalHops <= 15) {
|
||||||
|
reasons.push({ type: 'positive', message: `路由跳数较少 (${totalHops}跳),直连性好`, messageEn: `Few hops (${totalHops}), good directness` })
|
||||||
|
} else if (totalHops > 25) {
|
||||||
|
reasons.push({ type: 'negative', message: `路由跳数过多 (${totalHops}跳),增加延迟`, messageEn: `Excessive hops (${totalHops}), adds latency` })
|
||||||
|
} else if (totalHops > 20) {
|
||||||
|
reasons.push({ type: 'warning', message: `路由跳数略多 (${totalHops}跳)`, messageEn: `Slightly high hops (${totalHops})` })
|
||||||
|
} else if (totalHops > 0) {
|
||||||
|
reasons.push({ type: 'positive', message: `路由跳数正常 (${totalHops}跳)`, messageEn: `Normal hop count (${totalHops})` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
totalHops,
|
||||||
|
hasOptimizedRoute,
|
||||||
|
hopPenalty,
|
||||||
|
premiumBonus,
|
||||||
|
reasons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates detailed score with transmission (40pts), quality (30pts), balance (20pts), routing (10pts).
|
||||||
|
* Total: 100 points.
|
||||||
|
*/
|
||||||
|
export function calculateDetailedScore(
|
||||||
|
results: LatencyResult[],
|
||||||
|
ipInfo?: IpInfo | null,
|
||||||
|
traceroute?: TracerouteStats | null
|
||||||
|
): ScoringResult | null {
|
||||||
|
const completed = results.filter(r => r.status === 'success' || r.status === 'failed')
|
||||||
|
if (completed.length === 0) return null
|
||||||
|
|
||||||
|
const successNodes = completed.filter(r => r.status === 'success')
|
||||||
|
const transmission = calculateTransmissionScore(results, ipInfo)
|
||||||
|
const quality = calculateLinkQualityScore(results)
|
||||||
|
const balance = calculateGlobalBalanceScore(results, ipInfo)
|
||||||
|
const routing = calculateRoutingHealthScore(traceroute)
|
||||||
|
|
||||||
|
// Calculate average latency
|
||||||
|
let totalLatency = 0
|
||||||
|
let latencyCount = 0
|
||||||
|
for (const res of successNodes) {
|
||||||
|
const latencyMs = res.latency
|
||||||
|
if (latencyMs !== null && Number.isFinite(latencyMs)) {
|
||||||
|
totalLatency += latencyMs
|
||||||
|
latencyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const avgLatency = latencyCount > 0 ? Math.round(totalLatency / latencyCount) : null
|
||||||
|
|
||||||
|
// Total: transmission (40) + quality (30) + balance (20) + routing (10) = 100
|
||||||
|
const totalScore = transmission.score + quality.score + balance.score + routing.score
|
||||||
|
|
||||||
|
let grade = 'D'
|
||||||
|
let level: ScoringResult['level'] = 'poor'
|
||||||
|
|
||||||
|
if (totalScore >= 90) { grade = 'A+'; level = 'excellent' }
|
||||||
|
else if (totalScore >= 80) { grade = 'A'; level = 'great' }
|
||||||
|
else if (totalScore >= 70) { grade = 'B'; level = 'good' }
|
||||||
|
else if (totalScore >= 55) { grade = 'C'; level = 'fair' }
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalScore,
|
||||||
|
grade,
|
||||||
|
level,
|
||||||
|
transmission,
|
||||||
|
quality,
|
||||||
|
balance,
|
||||||
|
routing,
|
||||||
|
stats: {
|
||||||
|
success: successNodes.length,
|
||||||
|
failed: completed.length - successNodes.length,
|
||||||
|
total: completed.length,
|
||||||
|
avgLatency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ export interface IpInfo {
|
|||||||
asn: number
|
asn: number
|
||||||
org: string
|
org: string
|
||||||
isp: string
|
isp: string
|
||||||
|
lat?: number
|
||||||
|
lon?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestNode {
|
export interface TestNode {
|
||||||
@@ -20,10 +22,68 @@ export interface LatencyResult {
|
|||||||
nodeId: string
|
nodeId: string
|
||||||
latency: number | null
|
latency: number | null
|
||||||
status: 'pending' | 'testing' | 'success' | 'failed'
|
status: 'pending' | 'testing' | 'success' | 'failed'
|
||||||
|
// Extended stats for GCS scoring
|
||||||
|
stats?: {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
loss: number // packet loss percentage (0-100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traceroute result for routing health analysis
|
||||||
|
export interface TracerouteHop {
|
||||||
|
asn: number | null
|
||||||
|
rtt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TracerouteResult {
|
||||||
|
hops: TracerouteHop[]
|
||||||
|
totalHops: number
|
||||||
|
uniqueAsns: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LatencyLevel = 'excellent' | 'great' | 'good' | 'fair' | 'moderate' | 'poor' | 'timeout'
|
export type LatencyLevel = 'excellent' | 'great' | 'good' | 'fair' | 'moderate' | 'poor' | 'timeout'
|
||||||
|
|
||||||
|
// GCS (Global Connectivity Score) Types
|
||||||
|
export interface TracerouteStats {
|
||||||
|
totalHops: number
|
||||||
|
uniqueAsns: number
|
||||||
|
hasOptimizedRoute: boolean
|
||||||
|
detectedPremiumAsns?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreReason {
|
||||||
|
type: 'positive' | 'warning' | 'negative'
|
||||||
|
message: string
|
||||||
|
messageEn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreDimension {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
labelEn: string
|
||||||
|
score: number // 0-100 normalized score
|
||||||
|
weight: number // 0.0-1.0
|
||||||
|
color: string
|
||||||
|
reasons: ScoreReason[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalScore {
|
||||||
|
total: number
|
||||||
|
grade: string
|
||||||
|
level: 'excellent' | 'great' | 'good' | 'fair' | 'poor'
|
||||||
|
dimensions: {
|
||||||
|
transmission: ScoreDimension
|
||||||
|
quality: ScoreDimension
|
||||||
|
balance: ScoreDimension
|
||||||
|
routing: ScoreDimension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region definitions for balance scoring
|
||||||
|
export const REGIONS = ['North America', 'Europe', 'Asia', 'South America', 'Oceania', 'Africa', 'Middle East'] as const
|
||||||
|
export type Region = typeof REGIONS[number]
|
||||||
|
|
||||||
export const LATENCY_THRESHOLDS = {
|
export const LATENCY_THRESHOLDS = {
|
||||||
excellent: 50, // < 50ms - deep green
|
excellent: 50, // < 50ms - deep green
|
||||||
great: 100, // 50-100ms - green
|
great: 100, // 50-100ms - green
|
||||||
|
|||||||
@@ -40,16 +40,49 @@ interface MeasurementResult {
|
|||||||
results?: ProbeResult[]
|
results?: ProbeResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TracerouteHopResult {
|
||||||
|
resolvedHostname?: string
|
||||||
|
resolvedAddress?: string
|
||||||
|
timings: Array<{ rtt: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TracerouteProbeResult {
|
||||||
|
probe: {
|
||||||
|
continent: string
|
||||||
|
country: string
|
||||||
|
city: string
|
||||||
|
asn: number
|
||||||
|
network: string
|
||||||
|
}
|
||||||
|
result: {
|
||||||
|
status: string
|
||||||
|
hops: TracerouteHopResult[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TracerouteMeasurementResult {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
status: 'in-progress' | 'finished'
|
||||||
|
results?: TracerouteProbeResult[]
|
||||||
|
}
|
||||||
|
|
||||||
interface BatchLatencyResult {
|
interface BatchLatencyResult {
|
||||||
nodeId: string
|
nodeId: string
|
||||||
latency: number | null
|
latency: number | null
|
||||||
success: boolean
|
success: boolean
|
||||||
|
stats?: {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
loss: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SavedResult {
|
interface SavedResult {
|
||||||
type: 'single' | 'compare'
|
type: 'single' | 'compare'
|
||||||
input: { target: string } | { leftTarget: string; rightTarget: string }
|
input: { target: string } | { leftTarget: string; rightTarget: string }
|
||||||
results: BatchLatencyResult[] | { left: BatchLatencyResult[]; right: BatchLatencyResult[] }
|
results: BatchLatencyResult[] | { left: BatchLatencyResult[]; right: BatchLatencyResult[] }
|
||||||
|
traceroute?: TracerouteStats | { left: TracerouteStats | null; right: TracerouteStats | null } | null
|
||||||
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
@@ -63,6 +96,8 @@ interface IpApiResponse {
|
|||||||
as?: string
|
as?: string
|
||||||
org?: string
|
org?: string
|
||||||
isp?: string
|
isp?: string
|
||||||
|
lat?: number
|
||||||
|
lon?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageState {
|
interface UsageState {
|
||||||
@@ -77,6 +112,15 @@ const KV_TARGET_BYTES = Math.floor(KV_LIMIT_BYTES * 0.8) // 80% target after cle
|
|||||||
const FULL_SCAN_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
|
const FULL_SCAN_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
|
||||||
const RESULT_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
const RESULT_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
||||||
|
|
||||||
|
// China premium network ASNs for routing quality detection
|
||||||
|
const CHINA_PREMIUM_ASNS = new Set([
|
||||||
|
58807, // CMIN2 (China Mobile International Premium)
|
||||||
|
10099, // CUG (China Unicom Premium)
|
||||||
|
4809, // CN2 (China Telecom Premium)
|
||||||
|
9929, // CUII (China Unicom Industrial Internet)
|
||||||
|
23764 // CTG (China Telecom Global)
|
||||||
|
])
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
@@ -110,6 +154,25 @@ function isValidTarget(target: string): boolean {
|
|||||||
return IP_REGEX.test(target) || DOMAIN_REGEX.test(target)
|
return IP_REGEX.test(target) || DOMAIN_REGEX.test(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIPAddress(target: string): boolean {
|
||||||
|
return IP_REGEX.test(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve domain to IP using Cloudflare DNS over HTTPS
|
||||||
|
async function resolveDomainToIP(domain: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=A`, {
|
||||||
|
headers: { 'Accept': 'application/dns-json' }
|
||||||
|
})
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json() as { Answer?: Array<{ type: number; data: string }> }
|
||||||
|
const aRecord = data.Answer?.find(r => r.type === 1)
|
||||||
|
return aRecord?.data || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractClientIp(request: Request): string | null {
|
function extractClientIp(request: Request): string | null {
|
||||||
const cfConnectingIp = request.headers.get('CF-Connecting-IP')
|
const cfConnectingIp = request.headers.get('CF-Connecting-IP')
|
||||||
if (cfConnectingIp) return cfConnectingIp
|
if (cfConnectingIp) return cfConnectingIp
|
||||||
@@ -153,6 +216,145 @@ async function createBatchMeasurement(target: string, env: Env): Promise<string>
|
|||||||
return data.id
|
return data.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// China ISP probe locations for premium route detection
|
||||||
|
const CHINA_ISP_PROBES = [
|
||||||
|
{ country: 'CN', asn: '9808', isp: 'cm' }, // China Mobile
|
||||||
|
{ country: 'CN', asn: '17622', isp: 'ct' }, // China Telecom
|
||||||
|
{ country: 'CN', asn: '151185', isp: 'cu' } // China Unicom
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Create MTR measurements from all 3 China ISP nodes (MTR provides ASN info)
|
||||||
|
async function createMtrMeasurement(target: string, env: Env): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${env.GLOBALPING_API}/measurements`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'LatencyTest/1.0.0'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'mtr',
|
||||||
|
target,
|
||||||
|
locations: CHINA_ISP_PROBES.map(p => ({ country: p.country, asn: parseInt(p.asn), limit: 1 })),
|
||||||
|
measurementOptions: {
|
||||||
|
protocol: 'ICMP'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) return null
|
||||||
|
|
||||||
|
const data = await res.json() as MeasurementResponse
|
||||||
|
return data.id
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TracerouteStats {
|
||||||
|
totalHops: number
|
||||||
|
uniqueAsns: number
|
||||||
|
hasOptimizedRoute: boolean
|
||||||
|
detectedPremiumAsns?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MtrHop {
|
||||||
|
asn: number[]
|
||||||
|
resolvedAddress?: string | null
|
||||||
|
resolvedHostname?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPremiumAsns(hops: MtrHop[]): number[] {
|
||||||
|
const detected = new Set<number>()
|
||||||
|
|
||||||
|
for (const hop of hops) {
|
||||||
|
// MTR provides ASN directly in the asn array
|
||||||
|
if (hop.asn && Array.isArray(hop.asn)) {
|
||||||
|
for (const asn of hop.asn) {
|
||||||
|
if (CHINA_PREMIUM_ASNS.has(asn)) {
|
||||||
|
detected.add(asn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check CN2 IP prefix (59.43.*)
|
||||||
|
if (hop.resolvedAddress?.startsWith('59.43.')) {
|
||||||
|
detected.add(4809)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hostname patterns as fallback
|
||||||
|
if (hop.resolvedHostname) {
|
||||||
|
const hostname = hop.resolvedHostname.toLowerCase()
|
||||||
|
if (hostname.includes('cn2') || hostname.includes('ctcn2')) {
|
||||||
|
detected.add(4809)
|
||||||
|
}
|
||||||
|
if (hostname.includes('ctg') || hostname.includes('ctgnet')) {
|
||||||
|
detected.add(23764)
|
||||||
|
}
|
||||||
|
if (hostname.includes('cmin2')) {
|
||||||
|
detected.add(58807)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(detected)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MtrMeasurementResult {
|
||||||
|
status: string
|
||||||
|
results?: Array<{
|
||||||
|
probe?: { asn?: number; network?: string }
|
||||||
|
result?: { hops?: MtrHop[] }
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMtrStats(measurementId: string, env: Env): Promise<TracerouteStats | null> {
|
||||||
|
try {
|
||||||
|
// Wait for MTR to complete (takes longer than ping)
|
||||||
|
await new Promise(r => setTimeout(r, 10000))
|
||||||
|
|
||||||
|
const res = await fetch(`${env.GLOBALPING_API}/measurements/${measurementId}`, {
|
||||||
|
headers: { 'User-Agent': 'LatencyTest/1.0.0' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) return null
|
||||||
|
|
||||||
|
const data = await res.json() as MtrMeasurementResult
|
||||||
|
if (data.status !== 'finished' || !data.results?.length) return null
|
||||||
|
|
||||||
|
const allDetectedAsns = new Set<number>()
|
||||||
|
let totalHops = 0
|
||||||
|
const allAsns = new Set<number>()
|
||||||
|
|
||||||
|
// Process results from each ISP probe
|
||||||
|
for (const result of data.results) {
|
||||||
|
const hops = result.result?.hops || []
|
||||||
|
totalHops += hops.length
|
||||||
|
|
||||||
|
const detected = detectPremiumAsns(hops)
|
||||||
|
detected.forEach(asn => allDetectedAsns.add(asn))
|
||||||
|
|
||||||
|
// Count unique ASNs from hop.asn arrays
|
||||||
|
for (const hop of hops) {
|
||||||
|
if (hop.asn && Array.isArray(hop.asn)) {
|
||||||
|
hop.asn.forEach(asn => allAsns.add(asn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgHops = Math.round(totalHops / Math.max(data.results.length, 1))
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalHops: avgHops,
|
||||||
|
uniqueAsns: allAsns.size || Math.ceil(avgHops / 3),
|
||||||
|
hasOptimizedRoute: allDetectedAsns.size > 0,
|
||||||
|
detectedPremiumAsns: allDetectedAsns.size > 0 ? Array.from(allDetectedAsns) : undefined
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLocationName(value?: string | null): string {
|
function normalizeLocationName(value?: string | null): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
return value
|
return value
|
||||||
@@ -195,7 +397,7 @@ async function lookupIpInfo(ip: string): Promise<IpInfo | null> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,message,query,country,city,as,org,isp`,
|
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,message,query,country,city,as,org,isp,lat,lon`,
|
||||||
{ headers: { 'User-Agent': 'LatencyTest/1.0.0' } }
|
{ headers: { 'User-Agent': 'LatencyTest/1.0.0' } }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,7 +415,9 @@ async function lookupIpInfo(ip: string): Promise<IpInfo | null> {
|
|||||||
city: data.city || '',
|
city: data.city || '',
|
||||||
asn: parseAsn(data.as),
|
asn: parseAsn(data.as),
|
||||||
org: data.org || '',
|
org: data.org || '',
|
||||||
isp: data.isp || ''
|
isp: data.isp || '',
|
||||||
|
lat: data.lat,
|
||||||
|
lon: data.lon
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('IP lookup error:', error)
|
console.error('IP lookup error:', error)
|
||||||
@@ -338,8 +542,22 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
|
|||||||
return jsonResponse({ error: 'Invalid target. Please enter a valid IP address or domain name.' }, 400)
|
return jsonResponse({ error: 'Invalid target. Please enter a valid IP address or domain name.' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const measurementId = await createBatchMeasurement(trimmedTarget, env)
|
// Resolve domain to IP for traceroute (GlobalPing requires IP)
|
||||||
return jsonResponse({ measurementId })
|
let tracerouteTarget = trimmedTarget
|
||||||
|
if (!isIPAddress(trimmedTarget)) {
|
||||||
|
const resolvedIP = await resolveDomainToIP(trimmedTarget)
|
||||||
|
if (resolvedIP) {
|
||||||
|
tracerouteTarget = resolvedIP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create both ping and traceroute measurements in parallel
|
||||||
|
const [measurementId, tracerouteId] = await Promise.all([
|
||||||
|
createBatchMeasurement(trimmedTarget, env),
|
||||||
|
createMtrMeasurement(tracerouteTarget, env)
|
||||||
|
])
|
||||||
|
|
||||||
|
return jsonResponse({ measurementId, tracerouteId })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Batch measurement creation error:', error)
|
console.error('Batch measurement creation error:', error)
|
||||||
return jsonResponse({ error: 'Failed to create measurement' }, 500)
|
return jsonResponse({ error: 'Failed to create measurement' }, 500)
|
||||||
@@ -375,9 +593,18 @@ async function handleGetMeasurement(measurementId: string, env: Env): Promise<Re
|
|||||||
|
|
||||||
if (nodeId && !matchedNodes.has(nodeId)) {
|
if (nodeId && !matchedNodes.has(nodeId)) {
|
||||||
matchedNodes.add(nodeId)
|
matchedNodes.add(nodeId)
|
||||||
if (result.status === 'finished') {
|
if (result.status === 'finished' && result.stats) {
|
||||||
const latency = result.stats?.avg != null ? Math.round(result.stats.avg) : null
|
const latency = result.stats.avg != null ? Math.round(result.stats.avg) : null
|
||||||
results.push({ nodeId, latency, success: latency !== null })
|
results.push({
|
||||||
|
nodeId,
|
||||||
|
latency,
|
||||||
|
success: latency !== null,
|
||||||
|
stats: {
|
||||||
|
min: result.stats.min ?? 0,
|
||||||
|
max: result.stats.max ?? 0,
|
||||||
|
loss: result.stats.loss ?? 0
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
results.push({ nodeId, latency: null, success: false })
|
results.push({ nodeId, latency: null, success: false })
|
||||||
}
|
}
|
||||||
@@ -418,6 +645,7 @@ async function handleSaveResult(request: Request, env: Env): Promise<Response> {
|
|||||||
type: 'single' | 'compare'
|
type: 'single' | 'compare'
|
||||||
input: { target: string } | { leftTarget: string; rightTarget: string }
|
input: { target: string } | { leftTarget: string; rightTarget: string }
|
||||||
results: BatchLatencyResult[] | { left: BatchLatencyResult[]; right: BatchLatencyResult[] }
|
results: BatchLatencyResult[] | { left: BatchLatencyResult[]; right: BatchLatencyResult[] }
|
||||||
|
traceroute?: TracerouteStats | { left: TracerouteStats | null; right: TracerouteStats | null } | null
|
||||||
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +662,7 @@ async function handleSaveResult(request: Request, env: Env): Promise<Response> {
|
|||||||
type: body.type,
|
type: body.type,
|
||||||
input: body.input,
|
input: body.input,
|
||||||
results: body.results,
|
results: body.results,
|
||||||
|
traceroute: body.traceroute ?? null,
|
||||||
ipInfo: body.ipInfo ?? null,
|
ipInfo: body.ipInfo ?? null,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
@@ -489,6 +718,19 @@ async function handleGetResult(id: string, env: Env): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetTraceroute(tracerouteId: string, env: Env): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const stats = await getMtrStats(tracerouteId, env)
|
||||||
|
if (!stats) {
|
||||||
|
return jsonResponse({ status: 'pending' })
|
||||||
|
}
|
||||||
|
return jsonResponse({ status: 'finished', ...stats })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get traceroute error:', error)
|
||||||
|
return jsonResponse({ error: 'Failed to get traceroute' }, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
@@ -513,6 +755,11 @@ export default {
|
|||||||
return handleGetMeasurement(batchMatch[1], env)
|
return handleGetMeasurement(batchMatch[1], env)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tracerouteMatch = path.match(/^\/api\/latency\/traceroute\/([a-zA-Z0-9-]+)$/)
|
||||||
|
if (tracerouteMatch && request.method === 'GET') {
|
||||||
|
return handleGetTraceroute(tracerouteMatch[1], env)
|
||||||
|
}
|
||||||
|
|
||||||
// Result saving/loading endpoints
|
// Result saving/loading endpoints
|
||||||
if (path === '/api/results' && request.method === 'POST') {
|
if (path === '/api/results' && request.method === 'POST') {
|
||||||
return handleSaveResult(request, env)
|
return handleSaveResult(request, env)
|
||||||
|
|||||||
Reference in New Issue
Block a user