diff --git a/src/client/api/latency.ts b/src/client/api/latency.ts index 1c8a058..8bd24ac 100644 --- a/src/client/api/latency.ts +++ b/src/client/api/latency.ts @@ -8,6 +8,7 @@ export interface IpInfoResponse { export interface BatchMeasurementResponse { measurementId: string + tracerouteId?: string | null } export interface BatchResultResponse { @@ -16,19 +17,37 @@ export interface BatchResultResponse { nodeId: string latency: number | null success: boolean + stats?: { + min: number + max: number + loss: number + } }> resolvedAddress?: string 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 { type: 'single' | 'compare' input: { target: string } | { leftTarget: string; rightTarget: string } - results: Array<{ nodeId: string; latency: number | null; success: boolean }> | { - left: Array<{ nodeId: string; latency: number | null; success: boolean }> - right: Array<{ nodeId: string; latency: number | null; success: boolean }> - } + results: SavedNodeResult[] | { left: SavedNodeResult[]; right: SavedNodeResult[] } ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null + traceroute?: TracerouteStats | { left: TracerouteStats | null; right: TracerouteStats | null } | null } export interface SaveResultResponse { @@ -39,11 +58,9 @@ export interface SaveResultResponse { export interface SavedResultData { type: 'single' | 'compare' input: { target: string } | { leftTarget: string; rightTarget: string } - results: Array<{ nodeId: string; latency: number | null; success: boolean }> | { - left: Array<{ nodeId: string; latency: number | null; success: boolean }> - right: Array<{ nodeId: string; latency: number | null; success: boolean }> - } + results: SavedNodeResult[] | { left: SavedNodeResult[]; right: SavedNodeResult[] } ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null + traceroute?: TracerouteStats | { left: TracerouteStats | null; right: TracerouteStats | null } | null createdAt: string } @@ -54,9 +71,17 @@ export async function fetchUserIp(): Promise { return data.ip } +export interface TracerouteStats { + totalHops: number + uniqueAsns: number + hasOptimizedRoute: boolean + detectedPremiumAsns?: number[] +} + export interface TestResult { resolvedAddress?: string ipInfo?: IpInfo | null + traceroute?: TracerouteStats | null } export async function testAllNodes( @@ -80,7 +105,7 @@ export async function testAllNodes( return {} } - const { measurementId }: BatchMeasurementResponse = await res.json() + const { measurementId, tracerouteId }: BatchMeasurementResponse = await res.json() for (const node of TEST_NODES) { onProgress({ nodeId: node.id, latency: null, status: 'testing' }) @@ -116,7 +141,8 @@ export async function testAllNodes( onProgress({ nodeId: result.nodeId, latency: result.latency, - status: 'success' + status: 'success', + stats: result.stats }) } } @@ -127,7 +153,8 @@ export async function testAllNodes( onProgress({ nodeId: result.nodeId, 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 { diff --git a/src/client/components/ComparePage.css b/src/client/components/ComparePage.css index 01a4c86..319cea0 100644 --- a/src/client/components/ComparePage.css +++ b/src/client/components/ComparePage.css @@ -368,6 +368,13 @@ margin-bottom: 1rem; } +.compare-score-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + .compare-ip-card { flex: 1; } @@ -408,7 +415,8 @@ margin: -0.5rem 0; } - .compare-ip-info-cards { + .compare-ip-info-cards, + .compare-score-cards { grid-template-columns: 1fr; } diff --git a/src/client/components/ComparePage.tsx b/src/client/components/ComparePage.tsx index ed23e20..4f3c301 100644 --- a/src/client/components/ComparePage.tsx +++ b/src/client/components/ComparePage.tsx @@ -2,11 +2,12 @@ import { useState, useEffect } from 'react' import { useParams } from 'react-router-dom' import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency' 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 ExpirationBanner from './ExpirationBanner' import ShareLinkCard from './ShareLinkCard' import IpInfoCard from './IpInfoCard' +import ScoreCard from './ScoreCard' 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)$/ @@ -36,6 +37,8 @@ export default function ComparePage() { const [resolvedIpB, setResolvedIpB] = useState(null) const [ipInfoA, setIpInfoA] = useState(null) const [ipInfoB, setIpInfoB] = useState(null) + const [tracerouteA, setTracerouteA] = useState(null) + const [tracerouteB, setTracerouteB] = useState(null) useEffect(() => { if (!resultId) { @@ -50,6 +53,8 @@ export default function ComparePage() { setResolvedIpB(null) setIpInfoA(null) setIpInfoB(null) + setTracerouteA(null) + setTracerouteB(null) return } @@ -68,8 +73,8 @@ export default function ComparePage() { setTargetB(input.rightTarget) const savedResults = data.results as { - left: Array<{ nodeId: string; latency: number | null; success: boolean }> - right: 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; stats?: { min: number; max: number; loss: number } }> } const leftMap = new Map() @@ -79,7 +84,8 @@ export default function ComparePage() { leftMap.set(r.nodeId, { nodeId: r.nodeId, 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, { nodeId: r.nodeId, 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.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(() => { setLoadError(t('无法加载测试结果', 'Failed to load test result')) @@ -132,9 +146,11 @@ export default function ComparePage() { setResolvedIpB(null) setIpInfoA(null) setIpInfoB(null) + setTracerouteA(null) + setTracerouteB(null) - const leftResults = new Map() - const rightResults = new Map() + const leftResults = new Map() + const rightResults = new Map() try { const [resA, resB] = await Promise.all([ @@ -144,7 +160,8 @@ export default function ComparePage() { leftResults.set(res.nodeId, { nodeId: res.nodeId, 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, { nodeId: res.nodeId, 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) { 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) ?? { nodeId: node.id, latency: null, success: false @@ -186,7 +210,8 @@ export default function ComparePage() { type: 'compare', input: { leftTarget: trimmedA, rightTarget: trimmedB }, 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) setShowShareModal(true) @@ -272,12 +297,14 @@ export default function ComparePage() { {t('目标 A', 'Target A')} {targetA} {ipInfoA && } + {resultsA.size > 0 && }
VS
{t('目标 B', 'Target B')} {targetB} {ipInfoB && } + {resultsB.size > 0 && }
{t('只读模式', 'View Only')}
@@ -325,7 +352,7 @@ export default function ComparePage() { - {shareUrl && !testing && ( + {(shareUrl || resultsA.size > 0 || resultsB.size > 0) && !testing && (
{(ipInfoA || ipInfoB) && (
@@ -333,7 +360,13 @@ export default function ComparePage() { {ipInfoB && }
)} - + {(resultsA.size > 0 || resultsB.size > 0) && ( +
+ {resultsA.size > 0 && } + {resultsB.size > 0 && } +
+ )} + {shareUrl && }
)} diff --git a/src/client/components/HomePage.tsx b/src/client/components/HomePage.tsx index 1870379..89eb118 100644 --- a/src/client/components/HomePage.tsx +++ b/src/client/components/HomePage.tsx @@ -7,8 +7,9 @@ import ShareModal from './ShareModal' import ExpirationBanner from './ExpirationBanner' import ShareLinkCard from './ShareLinkCard' import IpInfoCard from './IpInfoCard' +import ScoreCard from './ScoreCard' 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' export default function HomePage() { @@ -25,6 +26,7 @@ export default function HomePage() { const [showShareModal, setShowShareModal] = useState(false) const [resolvedIp, setResolvedIp] = useState(null) const [ipInfo, setIpInfo] = useState(null) + const [traceroute, setTraceroute] = useState(null) const { t } = useLanguage() // Load saved result if viewing shared link @@ -45,11 +47,12 @@ export default function HomePage() { setTarget(input.target) const resultsMap = new Map() - 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, { nodeId: r.nodeId, latency: r.latency, - status: r.success ? 'success' : 'failed' + status: r.success ? 'success' : 'failed', + stats: r.stats }) } setResults(resultsMap) @@ -58,6 +61,11 @@ export default function HomePage() { if (data.ipInfo && 'ip' in data.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(() => { setError(t('无法加载测试结果', 'Failed to load test result')) @@ -74,17 +82,19 @@ export default function HomePage() { setShareUrl(null) setResolvedIp(null) setIpInfo(null) + setTraceroute(null) 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)) if (result.status === 'success' || result.status === 'failed') { finalResults.push({ nodeId: result.nodeId, latency: result.latency, - success: result.status === 'success' + success: result.status === 'success', + stats: result.stats }) } }) @@ -97,13 +107,18 @@ export default function HomePage() { setIpInfo(fetchedIpInfo) } + if (fetchedTraceroute) { + setTraceroute(fetchedTraceroute) + } + // Save result and get share URL try { const { shareUrl: url } = await saveResult({ type: 'single', input: { target: testTarget }, results: finalResults, - ipInfo: fetchedIpInfo + ipInfo: fetchedIpInfo, + traceroute: fetchedTraceroute }) setShareUrl(url) setShowShareModal(true) @@ -152,12 +167,13 @@ export default function HomePage() { {target} {ipInfo && } + {results.size > 0 && }
{t('只读模式', 'View Only')}
) : ( <> - {(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl) && ( + {(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl || results.size > 0) && (
{ipInfo ? ( @@ -167,6 +183,7 @@ export default function HomePage() { {resolvedIp}
) : null} + {results.size > 0 && } {shareUrl && } )} diff --git a/src/client/components/ScoreBadges.tsx b/src/client/components/ScoreBadges.tsx new file mode 100644 index 0000000..2feae7e --- /dev/null +++ b/src/client/components/ScoreBadges.tsx @@ -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 = { + // 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 ( +
+ {badges.map(({ asn, config }) => ( + + + {config.name} + + ))} +
+ ) +} diff --git a/src/client/components/ScoreCard.css b/src/client/components/ScoreCard.css new file mode 100644 index 0000000..34afa38 --- /dev/null +++ b/src/client/components/ScoreCard.css @@ -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; + } +} diff --git a/src/client/components/ScoreCard.tsx b/src/client/components/ScoreCard.tsx new file mode 100644 index 0000000..b008fb1 --- /dev/null +++ b/src/client/components/ScoreCard.tsx @@ -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>(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 ( +
+ {reasons.slice(1).map((r, i) => ( +
+ + {language === 'en' ? r.messageEn : r.message} +
+ ))} +
+ ) + } + + return ( +
+
+
+ {t('网络质量评分', 'Network Quality Score')} +
+ {totalScore} + / 100 +
+
+ {grade} +
+ +
+
toggleDim('transmission')}> +
+ {t('传输效率', 'Transmission')} + + {transmission.score} + /40 + +
+
+
+
+
+ {getSummary(transmission.reasons)} + {transmission.reasons.length > 1 && } +
+ {renderDetails('transmission', transmission.reasons)} +
+ +
toggleDim('quality')}> +
+ {t('链路质量', 'Link Quality')} + + {quality.score} + /30 + +
+
+
+
+
+ {getSummary(quality.reasons)} + {quality.reasons.length > 1 && } +
+ {renderDetails('quality', quality.reasons)} +
+ +
toggleDim('balance')}> +
+ {t('全球均衡', 'Global Balance')} + + {balance.score} + /20 + +
+
+
+
+
+ {getSummary(balance.reasons)} + {balance.reasons.length > 1 && } +
+ {renderDetails('balance', balance.reasons)} +
+ +
toggleDim('routing')}> +
+ + {t('路由质量', 'Routing Health')} + {traceroute?.detectedPremiumAsns && } + + + {routing.score} + /10 + +
+
+
+
+
+ {getSummary(routing.reasons)} + {routing.reasons.length > 1 && } +
+ {renderDetails('routing', routing.reasons)} +
+
+ + {!compact && ( +
+
+ {t('成功', 'Success')}: + {stats.success} +
+
+ {t('平均延迟', 'Avg Latency')}: + {stats.avgLatency ?? '-'}ms +
+
+ )} +
+ ) +} diff --git a/src/client/utils/geo.ts b/src/client/utils/geo.ts new file mode 100644 index 0000000..26bd5d3 --- /dev/null +++ b/src/client/utils/geo.ts @@ -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) +} diff --git a/src/client/utils/scoring.ts b/src/client/utils/scoring.ts new file mode 100644 index 0000000..c28dce5 --- /dev/null +++ b/src/client/utils/scoring.ts @@ -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 + 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 = { + '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 = {} + 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 = {} + 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 + } + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 0cafc02..817235e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -5,6 +5,8 @@ export interface IpInfo { asn: number org: string isp: string + lat?: number + lon?: number } export interface TestNode { @@ -20,10 +22,68 @@ export interface LatencyResult { nodeId: string latency: number | null 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' +// 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 = { excellent: 50, // < 50ms - deep green great: 100, // 50-100ms - green diff --git a/src/worker/index.ts b/src/worker/index.ts index 52e5d2d..d93dc5d 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -40,16 +40,49 @@ interface MeasurementResult { 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 { nodeId: string latency: number | null success: boolean + stats?: { + min: number + max: number + loss: number + } } interface SavedResult { type: 'single' | 'compare' input: { target: string } | { leftTarget: string; rightTarget: string } results: BatchLatencyResult[] | { left: BatchLatencyResult[]; right: BatchLatencyResult[] } + traceroute?: TracerouteStats | { left: TracerouteStats | null; right: TracerouteStats | null } | null ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null createdAt: string } @@ -63,6 +96,8 @@ interface IpApiResponse { as?: string org?: string isp?: string + lat?: number + lon?: number } 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 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 = { 'Access-Control-Allow-Origin': '*', '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) } +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 { + 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 { const cfConnectingIp = request.headers.get('CF-Connecting-IP') if (cfConnectingIp) return cfConnectingIp @@ -153,6 +216,145 @@ async function createBatchMeasurement(target: string, env: Env): Promise 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 { + 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() + + 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 { + 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() + let totalHops = 0 + const allAsns = new Set() + + // 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 { if (!value) return '' return value @@ -195,7 +397,7 @@ async function lookupIpInfo(ip: string): Promise { try { 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' } } ) @@ -213,7 +415,9 @@ async function lookupIpInfo(ip: string): Promise { city: data.city || '', asn: parseAsn(data.as), org: data.org || '', - isp: data.isp || '' + isp: data.isp || '', + lat: data.lat, + lon: data.lon } } catch (error) { console.error('IP lookup error:', error) @@ -338,8 +542,22 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise { type: 'single' | 'compare' input: { target: string } | { leftTarget: string; rightTarget: string } results: BatchLatencyResult[] | { left: BatchLatencyResult[]; right: BatchLatencyResult[] } + traceroute?: TracerouteStats | { left: TracerouteStats | null; right: TracerouteStats | null } | null ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null } @@ -434,6 +662,7 @@ async function handleSaveResult(request: Request, env: Env): Promise { type: body.type, input: body.input, results: body.results, + traceroute: body.traceroute ?? null, ipInfo: body.ipInfo ?? null, createdAt: new Date().toISOString() } @@ -489,6 +718,19 @@ async function handleGetResult(id: string, env: Env): Promise { } } +async function handleGetTraceroute(tracerouteId: string, env: Env): Promise { + 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 { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url) @@ -513,6 +755,11 @@ export default { 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 if (path === '/api/results' && request.method === 'POST') { return handleSaveResult(request, env)