Compare commits
4 Commits
main
...
cloudflare
| Author | SHA256 | Date | |
|---|---|---|---|
|
75b4341330
|
|||
|
7fdaf4da3d
|
|||
|
d5a44d2862
|
|||
|
1c7837d50c
|
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.a6.wiki",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-ahdoawhfo",
|
||||
"ANTHROPIC_MODEL": "claude-opus-4-5-thinking"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__gemini__gemini",
|
||||
"mcp__codex__codex",
|
||||
"Bash(npm run build)",
|
||||
"Bash(npm install react-globe.gl three)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -173,3 +173,6 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.claude
|
||||
.idea
|
||||
.wrangler
|
||||
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@@ -1,8 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
9
.idea/LatencyTest.iml
generated
9
.idea/LatencyTest.iml
generated
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
5
.idea/inspectionProfiles/Project_Default.xml
generated
5
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/misc.xml
generated
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="25" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/LatencyTest.iml" filepath="$PROJECT_DIR$/.idea/LatencyTest.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
90
README.en-US.md
Normal file
90
README.en-US.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 🌐 LatencyTest
|
||||
|
||||
[](https://react.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://vitejs.dev/)
|
||||
[](https://workers.cloudflare.com/)
|
||||
[](https://expressjs.com/)
|
||||
|
||||
**[中文](README.md)** | English
|
||||
|
||||
---
|
||||
|
||||
## 📖 Introduction
|
||||
|
||||
**LatencyTest** is a modern global network latency testing tool. It leverages 20+ nodes worldwide to perform latency tests on target IPs or domains, visualizing the results on an interactive 3D globe.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| 🌍 **Global Testing** | Test from 20+ locations across Americas, Europe, Asia, etc. |
|
||||
| 🌐 **3D Visualization** | Real-time visualization using `react-globe.gl` |
|
||||
| ⚡ **Auto DNS Resolution** | Supports IP/Domain input with automatic resolution |
|
||||
| 📍 **GeoIP Info** | Displays location, ISP, and AS number for the target |
|
||||
| 🆚 **Compare Mode** | Test two targets side-by-side to compare performance |
|
||||
| 🔗 **Shareable Results** | Generate unique links for test reports (valid for 7 days) |
|
||||
| 🌗 **Bilingual** | Full support for English/Chinese |
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
**Frontend**
|
||||
- React 18 + TypeScript + Vite
|
||||
- react-globe.gl + Three.js (3D visualization)
|
||||
- React Router
|
||||
- Pure CSS (Responsive design)
|
||||
|
||||
**Backend** (Choose one)
|
||||
- Cloudflare Workers (Edge computing, recommended)
|
||||
- Node.js + Express
|
||||
|
||||
**APIs**
|
||||
- GlobalPing API (Latency measurement)
|
||||
- ip-api.com (GeoIP lookup)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/LatencyTest.git
|
||||
cd LatencyTest
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Option 1: Cloudflare Workers (Recommended)
|
||||
|
||||
```bash
|
||||
# Install and login to Wrangler
|
||||
npm install -g wrangler
|
||||
wrangler login
|
||||
|
||||
# Deploy
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
### Option 2: Node.js Server
|
||||
|
||||
```bash
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Start
|
||||
npm run start
|
||||
```
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
> *Screenshots to be added*
|
||||
|
||||
## 📄 License
|
||||
|
||||
TBD
|
||||
90
README.md
90
README.md
@@ -1,2 +1,90 @@
|
||||
# LatencyTest
|
||||
# 🌐 LatencyTest
|
||||
|
||||
[](https://react.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://vitejs.dev/)
|
||||
[](https://workers.cloudflare.com/)
|
||||
[](https://expressjs.com/)
|
||||
|
||||
中文 | **[English](README.en-US.md)**
|
||||
|
||||
---
|
||||
|
||||
## 📖 项目简介
|
||||
|
||||
**LatencyTest** 是一款现代化的全球网络延迟测试工具。利用分布在世界各地 20+ 个节点,对目标 IP 或域名进行延迟测试,并通过交互式 3D 地球可视化展示结果。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
| 功能 | 描述 |
|
||||
|------|------|
|
||||
| 🌍 **全球多节点测试** | 覆盖美洲、欧洲、亚洲等 20+ 个测试节点 |
|
||||
| 🌐 **3D 地球可视化** | 使用 `react-globe.gl` 实时展示测试路径与延迟 |
|
||||
| ⚡ **自动 DNS 解析** | 支持 IP 或域名输入,自动解析目标地址 |
|
||||
| 📍 **GeoIP 信息** | 显示目标 IP 的地理位置、ISP 及 AS 编号 |
|
||||
| 🆚 **对比模式** | 同时测试两个目标,直观对比全球延迟差异 |
|
||||
| 🔗 **结果分享** | 一键生成测试报告链接(有效期 7 天) |
|
||||
| 🌗 **双语支持** | 完美支持中英双语切换 |
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
**前端**
|
||||
- React 18 + TypeScript + Vite
|
||||
- react-globe.gl + Three.js(3D 可视化)
|
||||
- React Router(路由管理)
|
||||
- 纯 CSS(响应式设计)
|
||||
|
||||
**后端**(二选一)
|
||||
- Cloudflare Workers(边缘计算,推荐)
|
||||
- Node.js + Express
|
||||
|
||||
**API**
|
||||
- GlobalPing API(延迟测量)
|
||||
- ip-api.com(GeoIP 查询)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/LatencyTest.git
|
||||
cd LatencyTest
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📦 部署
|
||||
|
||||
### 方式一:Cloudflare Workers(推荐)
|
||||
|
||||
```bash
|
||||
# 安装并登录 Wrangler
|
||||
npm install -g wrangler
|
||||
wrangler login
|
||||
|
||||
# 部署
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
### 方式二:Node.js 服务器
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
npm run build
|
||||
|
||||
# 启动
|
||||
npm run start
|
||||
```
|
||||
|
||||
## 📸 截图
|
||||
|
||||
> *截图待添加*
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
待定
|
||||
|
||||
2240
package-lock.json
generated
2240
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -10,8 +10,11 @@
|
||||
"build": "npm run build:client && npm run build:server",
|
||||
"build:client": "tsc -b tsconfig.client.json && vite build",
|
||||
"build:server": "tsc -b tsconfig.server.json",
|
||||
"build:worker": "npm run build:client && esbuild src/worker/index.ts --bundle --outfile=dist/worker/index.js --format=esm --platform=browser --target=es2022 --alias:@shared=./src/shared",
|
||||
"preview": "vite preview",
|
||||
"start": "node dist/server/index.js"
|
||||
"start": "node dist/server/index.js",
|
||||
"deploy": "npm run build:worker && wrangler deploy",
|
||||
"dev:worker": "wrangler dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
@@ -21,10 +24,12 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-globe.gl": "^2.37.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241218.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.30",
|
||||
@@ -32,8 +37,10 @@
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"esbuild": "^0.24.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5"
|
||||
"vite": "^6.0.5",
|
||||
"wrangler": "^3.99.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,47 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
import FloatingHeader from './components/FloatingHeader'
|
||||
import IpInput from './components/IpInput'
|
||||
import LatencyMap from './components/LatencyMap'
|
||||
import ResultsPanel from './components/ResultsPanel'
|
||||
import { testAllNodes } from './api/latency'
|
||||
import { LatencyResult } from '@shared/types'
|
||||
import HomePage from './components/HomePage'
|
||||
import ComparePage from './components/ComparePage'
|
||||
import './styles/index.css'
|
||||
|
||||
function AppContent() {
|
||||
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleTest = useCallback(async (target: string) => {
|
||||
setTesting(true)
|
||||
setResults(new Map())
|
||||
setSelectedNodeId(null)
|
||||
|
||||
await testAllNodes(target, (result) => {
|
||||
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
||||
})
|
||||
|
||||
setTesting(false)
|
||||
}, [])
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<FloatingHeader />
|
||||
|
||||
<main className="app-main" style={{ paddingTop: '5rem' }}>
|
||||
<p className="app-description">
|
||||
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
||||
</p>
|
||||
<IpInput onTest={handleTest} testing={testing} />
|
||||
<LatencyMap
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
<ResultsPanel
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
<main className="app-main" style={{ paddingTop: '8rem' }}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>{t('© 2024 延迟测试。由 GlobalPing 提供支持。', '© 2024 Latency Test. Powered by GlobalPing.')}</p>
|
||||
<p>{t('Copyright © by ahdoawhfo All Rights Reserved. ', 'Copyright © by ahdoawhfo All Rights Reserved. ')}</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/result/:id" element={<HomePage />} />
|
||||
<Route path="/compare" element={<ComparePage />} />
|
||||
<Route path="/compare/result/:id" element={<ComparePage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<AppContent />
|
||||
<AppRoutes />
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LatencyResult, TEST_NODES } from '@shared/types'
|
||||
import { LatencyResult, TEST_NODES, IpInfo } from '@shared/types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface IpInfoResponse {
|
||||
|
||||
export interface BatchMeasurementResponse {
|
||||
measurementId: string
|
||||
tracerouteId?: string | null
|
||||
}
|
||||
|
||||
export interface BatchResultResponse {
|
||||
@@ -16,7 +17,51 @@ 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: 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 {
|
||||
id: string
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export interface SavedResultData {
|
||||
type: 'single' | 'compare'
|
||||
input: { target: string } | { leftTarget: string; rightTarget: string }
|
||||
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
|
||||
}
|
||||
|
||||
export async function fetchUserIp(): Promise<string> {
|
||||
@@ -26,10 +71,23 @@ export async function fetchUserIp(): Promise<string> {
|
||||
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(
|
||||
target: string,
|
||||
onProgress: (result: LatencyResult) => void
|
||||
): Promise<void> {
|
||||
): Promise<TestResult> {
|
||||
for (const node of TEST_NODES) {
|
||||
onProgress({ nodeId: node.id, latency: null, status: 'pending' })
|
||||
}
|
||||
@@ -44,10 +102,10 @@ export async function testAllNodes(
|
||||
for (const node of TEST_NODES) {
|
||||
onProgress({ nodeId: node.id, latency: null, status: 'failed' })
|
||||
}
|
||||
return
|
||||
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' })
|
||||
@@ -56,6 +114,8 @@ export async function testAllNodes(
|
||||
const startTime = Date.now()
|
||||
const timeout = 60000
|
||||
const completedNodes = new Set<string>()
|
||||
let resolvedAddress: string | undefined
|
||||
let ipInfo: IpInfo | null | undefined
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
@@ -65,13 +125,24 @@ export async function testAllNodes(
|
||||
|
||||
const data: BatchResultResponse = await pollRes.json()
|
||||
|
||||
// Capture resolved IP address
|
||||
if (!resolvedAddress && data.resolvedAddress) {
|
||||
resolvedAddress = data.resolvedAddress
|
||||
}
|
||||
|
||||
// Capture IP info when available
|
||||
if (!ipInfo && data.ipInfo) {
|
||||
ipInfo = data.ipInfo
|
||||
}
|
||||
|
||||
for (const result of data.results) {
|
||||
if (result.success && !completedNodes.has(result.nodeId)) {
|
||||
completedNodes.add(result.nodeId)
|
||||
onProgress({
|
||||
nodeId: result.nodeId,
|
||||
latency: result.latency,
|
||||
status: 'success'
|
||||
status: 'success',
|
||||
stats: result.stats
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -82,11 +153,59 @@ export async function testAllNodes(
|
||||
onProgress({
|
||||
nodeId: result.nodeId,
|
||||
latency: result.latency,
|
||||
status: result.success ? 'success' : 'failed'
|
||||
status: result.success ? 'success' : 'failed',
|
||||
stats: result.stats
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
const res = await fetch(`${API_BASE}/results`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to save result')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchSavedResult(id: string): Promise<SavedResultData> {
|
||||
const res = await fetch(`${API_BASE}/results/${id}`)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch result')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
433
src/client/components/ComparePage.css
Normal file
433
src/client/components/ComparePage.css
Normal file
@@ -0,0 +1,433 @@
|
||||
.compare-page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compare-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.compare-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.75rem;
|
||||
background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.compare-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.compare-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--card-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
filter: drop-shadow(var(--shadow-glow));
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.compare-input {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 1rem;
|
||||
transition: all var(--transition-fast);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.compare-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.compare-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: inset 0 0 20px rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.compare-input.error {
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.compare-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--error-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compare-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.compare-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 48px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compare-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(rgba(255,255,255,0.15), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.compare-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.compare-button:hover:not(:disabled)::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compare-button:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.compare-button .spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Results Table */
|
||||
.results-container {
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
filter: drop-shadow(var(--shadow-glow));
|
||||
}
|
||||
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: 0.875rem 1.25rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
background: var(--hover-bg);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.results-table tbody tr:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.results-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.node-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.node-city {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.node-country {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.latency-cell {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.latency-cell.better {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.latency-cell.worse {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.latency-cell.testing {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-cell {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diff-positive {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.diff-negative {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.diff-neutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Summary */
|
||||
.compare-summary {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.summary-value.winner-a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.summary-value.winner-b {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
/* Read-only Mode */
|
||||
.compare-inputs.readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.readonly-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.readonly-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.readonly-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.readonly-vs {
|
||||
font-weight: 800;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.readonly-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: 20px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Table header with resolved IP */
|
||||
.table-header-target {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.table-header-ip {
|
||||
font-size: 0.7rem;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
opacity: 0.7;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* IP Info Cards for compare results */
|
||||
.compare-ip-info-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.compare-score-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.compare-ip-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Share link card container */
|
||||
.compare-share-container {
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compare-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.compare-inputs.readonly {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.readonly-vs {
|
||||
font-size: 1rem;
|
||||
margin: -0.5rem 0;
|
||||
}
|
||||
|
||||
.compare-ip-info-cards,
|
||||
.compare-score-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.compare-summary {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
479
src/client/components/ComparePage.tsx
Normal file
479
src/client/components/ComparePage.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
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, 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)$/
|
||||
const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/
|
||||
|
||||
function isValidTarget(value: string): boolean {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
|
||||
}
|
||||
|
||||
export default function ComparePage() {
|
||||
const { id: resultId } = useParams<{ id: string }>()
|
||||
const isReadOnly = !!resultId
|
||||
const { t } = useLanguage()
|
||||
|
||||
const [targetA, setTargetA] = useState('')
|
||||
const [targetB, setTargetB] = useState('')
|
||||
const [resultsA, setResultsA] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [resultsB, setResultsB] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [errors, setErrors] = useState({ a: '', b: '' })
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(isReadOnly)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [resolvedIpA, setResolvedIpA] = useState<string | null>(null)
|
||||
const [resolvedIpB, setResolvedIpB] = useState<string | null>(null)
|
||||
const [ipInfoA, setIpInfoA] = 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(() => {
|
||||
if (!resultId) {
|
||||
// Reset state when navigating from shared result back to interactive mode
|
||||
setTargetA('')
|
||||
setTargetB('')
|
||||
setResultsA(new Map())
|
||||
setResultsB(new Map())
|
||||
setShareUrl(null)
|
||||
setLoadError(null)
|
||||
setResolvedIpA(null)
|
||||
setResolvedIpB(null)
|
||||
setIpInfoA(null)
|
||||
setIpInfoB(null)
|
||||
setTracerouteA(null)
|
||||
setTracerouteB(null)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
|
||||
fetchSavedResult(resultId)
|
||||
.then((data) => {
|
||||
if (data.type !== 'compare') {
|
||||
setLoadError(t('无效的结果类型', 'Invalid result type'))
|
||||
return
|
||||
}
|
||||
|
||||
const input = data.input as { leftTarget: string; rightTarget: string }
|
||||
setTargetA(input.leftTarget)
|
||||
setTargetB(input.rightTarget)
|
||||
|
||||
const savedResults = data.results as {
|
||||
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<string, LatencyResult>()
|
||||
const rightMap = new Map<string, LatencyResult>()
|
||||
|
||||
for (const r of savedResults.left) {
|
||||
leftMap.set(r.nodeId, {
|
||||
nodeId: r.nodeId,
|
||||
latency: r.latency,
|
||||
status: r.success ? 'success' : 'failed',
|
||||
stats: r.stats
|
||||
})
|
||||
}
|
||||
|
||||
for (const r of savedResults.right) {
|
||||
rightMap.set(r.nodeId, {
|
||||
nodeId: r.nodeId,
|
||||
latency: r.latency,
|
||||
status: r.success ? 'success' : 'failed',
|
||||
stats: r.stats
|
||||
})
|
||||
}
|
||||
|
||||
setResultsA(leftMap)
|
||||
setResultsB(rightMap)
|
||||
|
||||
// Load saved IP info for readonly view
|
||||
if (data.ipInfo && 'left' in data.ipInfo) {
|
||||
const savedIpInfo = data.ipInfo as { left: IpInfo | null; right: IpInfo | null }
|
||||
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'))
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [resultId, t])
|
||||
|
||||
const handleCompare = async () => {
|
||||
if (isReadOnly) return
|
||||
|
||||
const trimmedA = targetA.trim()
|
||||
const trimmedB = targetB.trim()
|
||||
const aValid = isValidTarget(trimmedA)
|
||||
const bValid = isValidTarget(trimmedB)
|
||||
|
||||
setErrors({
|
||||
a: aValid ? '' : t('无效的目标', 'Invalid target'),
|
||||
b: bValid ? '' : t('无效的目标', 'Invalid target')
|
||||
})
|
||||
|
||||
if (!aValid || !bValid) return
|
||||
|
||||
setTesting(true)
|
||||
setResultsA(new Map())
|
||||
setResultsB(new Map())
|
||||
setShareUrl(null)
|
||||
setResolvedIpA(null)
|
||||
setResolvedIpB(null)
|
||||
setIpInfoA(null)
|
||||
setIpInfoB(null)
|
||||
setTracerouteA(null)
|
||||
setTracerouteB(null)
|
||||
|
||||
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; stats?: { min: number; max: number; loss: number } }>()
|
||||
|
||||
try {
|
||||
const [resA, resB] = await Promise.all([
|
||||
testAllNodes(trimmedA, (res) => {
|
||||
setResultsA(prev => new Map(prev).set(res.nodeId, res))
|
||||
if (res.status === 'success' || res.status === 'failed') {
|
||||
leftResults.set(res.nodeId, {
|
||||
nodeId: res.nodeId,
|
||||
latency: res.latency,
|
||||
success: res.status === 'success',
|
||||
stats: res.stats
|
||||
})
|
||||
}
|
||||
}),
|
||||
testAllNodes(trimmedB, (res) => {
|
||||
setResultsB(prev => new Map(prev).set(res.nodeId, res))
|
||||
if (res.status === 'success' || res.status === 'failed') {
|
||||
rightResults.set(res.nodeId, {
|
||||
nodeId: res.nodeId,
|
||||
latency: res.latency,
|
||||
success: res.status === 'success',
|
||||
stats: res.stats
|
||||
})
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
if (resA.resolvedAddress) {
|
||||
setResolvedIpA(resA.resolvedAddress)
|
||||
}
|
||||
if (resB.resolvedAddress) {
|
||||
setResolvedIpB(resB.resolvedAddress)
|
||||
}
|
||||
|
||||
if (resA.ipInfo) {
|
||||
setIpInfoA(resA.ipInfo)
|
||||
}
|
||||
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
|
||||
})
|
||||
const right = TEST_NODES.map(node => rightResults.get(node.id) ?? {
|
||||
nodeId: node.id, latency: null, success: false
|
||||
})
|
||||
|
||||
try {
|
||||
const { shareUrl: url } = await saveResult({
|
||||
type: 'compare',
|
||||
input: { leftTarget: trimmedA, rightTarget: trimmedB },
|
||||
results: { left, right },
|
||||
ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null },
|
||||
traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null }
|
||||
})
|
||||
setShareUrl(url)
|
||||
setShowShareModal(true)
|
||||
} catch (e) {
|
||||
console.error('Failed to save result:', e)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Comparison test failed:', e)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getDiff = (a: number | null, b: number | null) => {
|
||||
if (a === null || b === null) return null
|
||||
return b - a
|
||||
}
|
||||
|
||||
const getDiffDisplay = (diff: number | null) => {
|
||||
if (diff === null) return <span className="diff-neutral">-</span>
|
||||
const sign = diff > 0 ? '+' : ''
|
||||
const className = diff > 0 ? 'diff-positive' : diff < 0 ? 'diff-negative' : 'diff-neutral'
|
||||
return <span className={className}>{sign}{diff.toFixed(0)} ms</span>
|
||||
}
|
||||
|
||||
// Calculate summary stats
|
||||
const calculateSummary = () => {
|
||||
let winsA = 0, winsB = 0, totalDiff = 0, count = 0
|
||||
|
||||
for (const node of TEST_NODES) {
|
||||
const rA = resultsA.get(node.id)
|
||||
const rB = resultsB.get(node.id)
|
||||
const lA = rA?.status === 'success' ? rA.latency : null
|
||||
const lB = rB?.status === 'success' ? rB.latency : null
|
||||
|
||||
if (lA !== null && lB !== null) {
|
||||
if (lA < lB) winsA++
|
||||
else if (lB < lA) winsB++
|
||||
totalDiff += lB - lA
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return { winsA, winsB, avgDiff: count > 0 ? totalDiff / count : 0 }
|
||||
}
|
||||
|
||||
const summary = calculateSummary()
|
||||
const hasResults = resultsA.size > 0 || resultsB.size > 0
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<span className="spinner" />
|
||||
<p>{t('加载中...', 'Loading...')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<p className="error-message">{loadError}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentUrl = isReadOnly ? window.location.href : undefined
|
||||
|
||||
return (
|
||||
<div className="compare-page">
|
||||
<div className="compare-header">
|
||||
<h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
|
||||
<p className="compare-subtitle">
|
||||
{t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="compare-inputs readonly">
|
||||
<div className="readonly-input-group">
|
||||
<span className="readonly-label">{t('目标 A', 'Target A')}</span>
|
||||
<span className="readonly-value">{targetA}</span>
|
||||
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
|
||||
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
|
||||
</div>
|
||||
<div className="readonly-vs">VS</div>
|
||||
<div className="readonly-input-group">
|
||||
<span className="readonly-label">{t('目标 B', 'Target B')}</span>
|
||||
<span className="readonly-value">{targetB}</span>
|
||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
||||
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
|
||||
</div>
|
||||
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="compare-inputs">
|
||||
<div className="input-group">
|
||||
<label className="input-label">{t('目标 A', 'Target A')}</label>
|
||||
<input
|
||||
className={`compare-input ${errors.a ? 'error' : ''}`}
|
||||
value={targetA}
|
||||
onChange={(e) => { setTargetA(e.target.value); setErrors(prev => ({ ...prev, a: '' })) }}
|
||||
placeholder={t('输入IP或域名', 'Enter IP or domain')}
|
||||
disabled={testing}
|
||||
/>
|
||||
{errors.a && <span className="input-error">{errors.a}</span>}
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">{t('目标 B', 'Target B')}</label>
|
||||
<input
|
||||
className={`compare-input ${errors.b ? 'error' : ''}`}
|
||||
value={targetB}
|
||||
onChange={(e) => { setTargetB(e.target.value); setErrors(prev => ({ ...prev, b: '' })) }}
|
||||
placeholder={t('输入IP或域名', 'Enter IP or domain')}
|
||||
disabled={testing}
|
||||
/>
|
||||
{errors.b && <span className="input-error">{errors.b}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compare-actions">
|
||||
<button
|
||||
className="compare-button"
|
||||
onClick={handleCompare}
|
||||
disabled={testing || !targetA.trim() || !targetB.trim()}
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<span className="spinner" />
|
||||
{t('测试中...', 'Testing...')}
|
||||
</>
|
||||
) : (
|
||||
t('开始对比', 'Start Comparison')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(shareUrl || resultsA.size > 0 || resultsB.size > 0) && !testing && (
|
||||
<div className="compare-share-container">
|
||||
{(ipInfoA || ipInfoB) && (
|
||||
<div className="compare-ip-info-cards">
|
||||
{ipInfoA && <IpInfoCard info={ipInfoA} compact className="compare-ip-card" />}
|
||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact className="compare-ip-card" />}
|
||||
</div>
|
||||
)}
|
||||
{(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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasResults && (
|
||||
<>
|
||||
<div className="results-container">
|
||||
<table className="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('节点', 'Node')}</th>
|
||||
<th>
|
||||
<div className="table-header-target">
|
||||
<span>{targetA || 'A'}</span>
|
||||
{resolvedIpA && !IP_REGEX.test(targetA.trim()) && (
|
||||
<span className="table-header-ip">{resolvedIpA}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div className="table-header-target">
|
||||
<span>{targetB || 'B'}</span>
|
||||
{resolvedIpB && !IP_REGEX.test(targetB.trim()) && (
|
||||
<span className="table-header-ip">{resolvedIpB}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th>{t('差值', 'Diff')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{TEST_NODES.map(node => {
|
||||
const rA = resultsA.get(node.id)
|
||||
const rB = resultsB.get(node.id)
|
||||
const lA = rA?.status === 'success' ? rA.latency : null
|
||||
const lB = rB?.status === 'success' ? rB.latency : null
|
||||
const diff = getDiff(lA, lB)
|
||||
|
||||
const getLatencyClass = (mine: number | null, other: number | null, status?: string) => {
|
||||
if (status === 'testing') return 'latency-cell testing'
|
||||
if (mine === null || other === null) return 'latency-cell'
|
||||
if (mine < other) return 'latency-cell better'
|
||||
if (mine > other) return 'latency-cell worse'
|
||||
return 'latency-cell'
|
||||
}
|
||||
|
||||
const formatLatency = (latency: number | null, status?: string) => {
|
||||
if (status === 'testing' || status === 'pending') return '...'
|
||||
if (latency === null) return '-'
|
||||
return `${latency.toFixed(0)} ms`
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={node.id}>
|
||||
<td>
|
||||
<div className="node-cell">
|
||||
<span className="node-city">{node.city || node.name}</span>
|
||||
<span className="node-country">{node.country}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={getLatencyClass(lA, lB, rA?.status)}>
|
||||
{formatLatency(lA, rA?.status)}
|
||||
</td>
|
||||
<td className={getLatencyClass(lB, lA, rB?.status)}>
|
||||
{formatLatency(lB, rB?.status)}
|
||||
</td>
|
||||
<td className="diff-cell">
|
||||
{getDiffDisplay(diff)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!testing && (summary.winsA > 0 || summary.winsB > 0) && (
|
||||
<div className="compare-summary">
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('A 胜出节点', 'A Wins')}</span>
|
||||
<span className={`summary-value ${summary.winsA > summary.winsB ? 'winner-a' : ''}`}>
|
||||
{summary.winsA}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('B 胜出节点', 'B Wins')}</span>
|
||||
<span className={`summary-value ${summary.winsB > summary.winsA ? 'winner-b' : ''}`}>
|
||||
{summary.winsB}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">{t('平均差值', 'Avg Diff')}</span>
|
||||
<span className="summary-value">
|
||||
{summary.avgDiff > 0 ? '+' : ''}{summary.avgDiff.toFixed(0)} ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ShareModal
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
shareUrl={shareUrl || ''}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
src/client/components/ExpirationBanner.css
Normal file
51
src/client/components/ExpirationBanner.css
Normal file
@@ -0,0 +1,51 @@
|
||||
.expiration-banner {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
padding: 12px 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .expiration-banner {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
color: var(--warning-color);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner-copy-btn {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.banner-copy-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.expiration-banner { padding: 1rem; }
|
||||
.banner-content { flex-direction: column; gap: 8px; }
|
||||
}
|
||||
43
src/client/components/ExpirationBanner.tsx
Normal file
43
src/client/components/ExpirationBanner.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import Toast from './Toast'
|
||||
import './ExpirationBanner.css'
|
||||
|
||||
interface ExpirationBannerProps {
|
||||
shareUrl?: string
|
||||
}
|
||||
|
||||
export default function ExpirationBanner({ shareUrl }: ExpirationBannerProps) {
|
||||
const { t } = useLanguage()
|
||||
const [showToast, setShowToast] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
if (shareUrl) {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setShowToast(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="expiration-banner">
|
||||
<div className="banner-content">
|
||||
<span className="banner-icon">⚠️</span>
|
||||
<p className="banner-text">
|
||||
{t('此结果链接随时可能失效。请及时保存重要数据。', 'This result link may expire at any time. Please save important data.')}
|
||||
</p>
|
||||
{shareUrl && (
|
||||
<button className="banner-copy-btn" onClick={handleCopy}>
|
||||
{t('复制链接', 'Copy Link')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Toast
|
||||
message={t('已复制到剪贴板!', 'Copied to clipboard!')}
|
||||
isVisible={showToast}
|
||||
onClose={() => setShowToast(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
.floating-header {
|
||||
position: fixed;
|
||||
top: 1.25rem;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
@@ -8,7 +8,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 3rem;
|
||||
padding: 0.75rem 2rem;
|
||||
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1);
|
||||
transition: opacity var(--transition-smooth);
|
||||
width: calc(100% - 2rem);
|
||||
width: 80%;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
@@ -30,19 +30,43 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
/* Brand Section - Premium Style */
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.header-title .title-icon {
|
||||
.header-brand:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.header-brand .title-icon {
|
||||
font-size: 1.4rem;
|
||||
filter: drop-shadow(0 0 8px var(--accent-glow));
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 0 10px rgba(60, 130, 250, 0.35));
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.header-brand:hover .title-icon {
|
||||
transform: rotate(8deg) scale(1.08);
|
||||
filter: drop-shadow(0 0 16px rgba(60, 130, 250, 0.5));
|
||||
}
|
||||
|
||||
.header-brand .brand-text {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
margin-left: 1.5rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
@@ -58,8 +82,9 @@
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.floating-header {
|
||||
top: 1rem;
|
||||
top: 0.75rem;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import ThemeSwitcher from './ThemeSwitcher'
|
||||
import LanguageSwitcher from './LanguageSwitcher'
|
||||
import LiquidGlassMenu from './LiquidGlassMenu'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import './FloatingHeader.css'
|
||||
|
||||
export default function FloatingHeader() {
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', label: t('首页', 'Home') },
|
||||
{ key: '/compare', label: t('延迟对比', 'Compare') }
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -21,10 +30,18 @@ export default function FloatingHeader() {
|
||||
|
||||
return (
|
||||
<header className={`floating-header ${isScrolled ? 'scrolled' : ''}`}>
|
||||
<div className="header-title">
|
||||
<Link to="/" className="header-brand">
|
||||
<span className="title-icon">🌐</span>
|
||||
{t('延迟测试', 'Latency Test')}
|
||||
</div>
|
||||
<span className="brand-text">{t('延迟测试', 'Latency Test')}</span>
|
||||
</Link>
|
||||
|
||||
<LiquidGlassMenu
|
||||
items={menuItems}
|
||||
selectedKey={location.pathname}
|
||||
onSelect={(key) => navigate(key)}
|
||||
className="header-nav"
|
||||
/>
|
||||
|
||||
<div className="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ThemeSwitcher />
|
||||
|
||||
211
src/client/components/HomePage.tsx
Normal file
211
src/client/components/HomePage.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import IpInput from './IpInput'
|
||||
import LatencyMap from './LatencyMap'
|
||||
import ResultsPanel from './ResultsPanel'
|
||||
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, TracerouteStats } from '@shared/types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
export default function HomePage() {
|
||||
const { id: resultId } = useParams<{ id: string }>()
|
||||
const isReadOnly = !!resultId
|
||||
|
||||
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [shareUrl, setShareUrl] = useState<string | null>(null)
|
||||
const [target, setTarget] = useState<string>('')
|
||||
const [loading, setLoading] = useState(isReadOnly)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [resolvedIp, setResolvedIp] = useState<string | null>(null)
|
||||
const [ipInfo, setIpInfo] = useState<IpInfo | null>(null)
|
||||
const [traceroute, setTraceroute] = useState<TracerouteStats | null>(null)
|
||||
const { t } = useLanguage()
|
||||
|
||||
// Load saved result if viewing shared link
|
||||
useEffect(() => {
|
||||
if (!resultId) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
fetchSavedResult(resultId)
|
||||
.then((data) => {
|
||||
if (data.type !== 'single') {
|
||||
setError(t('无效的结果类型', 'Invalid result type'))
|
||||
return
|
||||
}
|
||||
|
||||
const input = data.input as { target: string }
|
||||
setTarget(input.target)
|
||||
|
||||
const resultsMap = new Map<string, LatencyResult>()
|
||||
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',
|
||||
stats: r.stats
|
||||
})
|
||||
}
|
||||
setResults(resultsMap)
|
||||
|
||||
// Load saved IP info for readonly view
|
||||
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'))
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [resultId, t])
|
||||
|
||||
const handleTest = useCallback(async (testTarget: string) => {
|
||||
setTesting(true)
|
||||
setResults(new Map())
|
||||
setSelectedNodeId(null)
|
||||
setShareUrl(null)
|
||||
setResolvedIp(null)
|
||||
setIpInfo(null)
|
||||
setTraceroute(null)
|
||||
setTarget(testTarget)
|
||||
|
||||
const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }> = []
|
||||
|
||||
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',
|
||||
stats: result.stats
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (resolvedAddress) {
|
||||
setResolvedIp(resolvedAddress)
|
||||
}
|
||||
|
||||
if (fetchedIpInfo) {
|
||||
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,
|
||||
traceroute: fetchedTraceroute
|
||||
})
|
||||
setShareUrl(url)
|
||||
setShowShareModal(true)
|
||||
} catch (e) {
|
||||
console.error('Failed to save result:', e)
|
||||
}
|
||||
|
||||
setTesting(false)
|
||||
}, [])
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||
setSelectedNodeId(nodeId)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<span className="spinner" />
|
||||
<p>{t('加载中...', 'Loading...')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<p className="error-message">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentUrl = isReadOnly ? window.location.href : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="app-description">
|
||||
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
||||
</p>
|
||||
|
||||
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="readonly-input-container">
|
||||
<div className="readonly-input">
|
||||
<span className="readonly-label">{t('测试目标', 'Test Target')}</span>
|
||||
<span className="readonly-value">{target}</span>
|
||||
</div>
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<IpInput onTest={handleTest} testing={testing} />
|
||||
{(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl || results.size > 0) && (
|
||||
<div className="test-result-info">
|
||||
{ipInfo ? (
|
||||
<IpInfoCard info={ipInfo} compact />
|
||||
) : resolvedIp && target !== resolvedIp ? (
|
||||
<div className="resolved-ip-info">
|
||||
<span className="resolved-ip-label">{t('解析IP', 'Resolved IP')}</span>
|
||||
<span className="resolved-ip-value">{resolvedIp}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact testing={testing} />}
|
||||
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LatencyMap
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
<ResultsPanel
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
shareUrl={shareUrl || ''}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
168
src/client/components/IpInfoCard.css
Normal file
168
src/client/components/IpInfoCard.css
Normal file
@@ -0,0 +1,168 @@
|
||||
.ip-info-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.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%;
|
||||
}
|
||||
|
||||
.ip-info-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 10px 25px -5px var(--shadow-color), var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Normal Mode Styles */
|
||||
.ip-main-info {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ip-address-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ip-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ip-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.ip-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.ip-detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ip-detail-item.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Compact Mode Styles */
|
||||
.ip-info-card.compact {
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.ip-compact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ip-compact-row:not(:last-child) {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.ip-info-card.compact .ip-value {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ip-info-card.compact .ip-label {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.ip-location {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ip-org {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.ip-asn {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
background: var(--hover-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.ip-info-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.ip-value {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.ip-details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ip-detail-item.full-width {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.ip-compact-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.ip-compact-row:not(:last-child) {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ip-location, .ip-asn {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
62
src/client/components/IpInfoCard.tsx
Normal file
62
src/client/components/IpInfoCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { IpInfo } from '@shared/types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import './IpInfoCard.css'
|
||||
|
||||
interface IpInfoCardProps {
|
||||
info: IpInfo
|
||||
className?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function IpInfoCard({ info, className = '', compact = false }: IpInfoCardProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const asnDisplay = info.asn ? `AS${info.asn}` : '-'
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`ip-info-card compact ${className}`}>
|
||||
<div className="ip-compact-row">
|
||||
<span className="ip-label">IP</span>
|
||||
<span className="ip-value">{info.ip}</span>
|
||||
<span className="ip-location">{info.city && `${info.city}, `}{info.country || '-'}</span>
|
||||
</div>
|
||||
<div className="ip-compact-row">
|
||||
<span className="ip-org">{info.org || info.isp || '-'}</span>
|
||||
<span className="ip-asn">{asnDisplay}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`ip-info-card ${className}`}>
|
||||
<div className="ip-main-info">
|
||||
<div className="ip-address-row">
|
||||
<span className="ip-label">IP</span>
|
||||
<span className="ip-value">{info.ip}</span>
|
||||
</div>
|
||||
<div className="ip-location-row">
|
||||
<span className="location-text">
|
||||
{info.city && `${info.city}, `}{info.country || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ip-details-grid">
|
||||
<div className="ip-detail-item">
|
||||
<span className="detail-label">ISP</span>
|
||||
<span className="detail-value">{info.isp || '-'}</span>
|
||||
</div>
|
||||
<div className="ip-detail-item">
|
||||
<span className="detail-label">ASN</span>
|
||||
<span className="detail-value">{asnDisplay}</span>
|
||||
</div>
|
||||
<div className="ip-detail-item full-width">
|
||||
<span className="detail-label">{t('组织', 'Organization')}</span>
|
||||
<span className="detail-value">{info.org || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
|
||||
setTarget(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
placeholder={loading ? t('正在检测IP...', 'Detecting IP...') : t('输入IP或域名(如 8.8.8.8 或 google.com)', 'Enter IP or domain (e.g., 8.8.8.8 or google.com)')}
|
||||
placeholder={loading ? t('正在检测IP...', 'Detecting IP...') : t('输入IP或域名', 'Enter IP or domain')}
|
||||
className={`ip-input ${error ? 'ip-input-error' : ''}`}
|
||||
disabled={testing || loading}
|
||||
/>
|
||||
|
||||
176
src/client/components/LiquidGlassMenu.css
Normal file
176
src/client/components/LiquidGlassMenu.css
Normal file
@@ -0,0 +1,176 @@
|
||||
/* Liquid Glass Menu - Premium iOS 26 Redesign */
|
||||
|
||||
.liquid-glass-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Premium Glass Indicator - Refined depth and refraction */
|
||||
.liquid-glass-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
/* Multi-layer frosted glass effect */
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
rgba(255, 255, 255, 0.03) 40%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
);
|
||||
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border-radius: 999px;
|
||||
|
||||
/* Refined layered shadows for premium depth */
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
/* Inner top highlight - simulates rim light */
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
/* Inner bottom edge - subtle thickness */
|
||||
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
|
||||
/* Inner side highlights */
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.06),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.06),
|
||||
/* Soft ambient glow */
|
||||
0 8px 32px -8px rgba(0, 0, 0, 0.35),
|
||||
/* Contact shadow */
|
||||
0 2px 6px rgba(0, 0, 0, 0.12);
|
||||
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transform-origin: center center;
|
||||
will-change: transform, width;
|
||||
}
|
||||
|
||||
/* Top specular highlight - elegant gloss effect */
|
||||
.liquid-glass-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 38%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
mask-image: linear-gradient(to right, transparent, black 15%, black 85%, transparent);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 15%, black 85%, transparent);
|
||||
}
|
||||
|
||||
/* Bottom subtle reflection */
|
||||
.liquid-glass-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 20%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.06) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Light Theme - Brighter, more solid glass */
|
||||
[data-theme="light"] .liquid-glass-indicator {
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.75) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.55) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.04),
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.4),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.4),
|
||||
0 8px 24px -6px rgba(0, 0, 0, 0.15),
|
||||
0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-indicator::before {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.9) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-indicator::after {
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.3) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Menu items container */
|
||||
.liquid-glass-items {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Individual menu item */
|
||||
.liquid-glass-item {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
transition: color 0.28s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
border-radius: 999px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.liquid-glass-item:hover {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.liquid-glass-item.active {
|
||||
color: #fff;
|
||||
text-shadow: 0 0 20px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Light Theme Text */
|
||||
[data-theme="light"] .liquid-glass-item {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-item:hover {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
[data-theme="light"] .liquid-glass-item.active {
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.liquid-glass-item {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
112
src/client/components/LiquidGlassMenu.tsx
Normal file
112
src/client/components/LiquidGlassMenu.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import './LiquidGlassMenu.css'
|
||||
|
||||
interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface LiquidGlassMenuProps {
|
||||
items: MenuItem[]
|
||||
selectedKey: string
|
||||
onSelect: (key: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LiquidGlassMenu({
|
||||
items,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
className = ''
|
||||
}: LiquidGlassMenuProps) {
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({
|
||||
transform: 'translateX(0)',
|
||||
width: 0,
|
||||
opacity: 0,
|
||||
transition: 'none'
|
||||
})
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const isInitialRender = useRef(true)
|
||||
|
||||
const selectedIndex = items.findIndex(item => item.key === selectedKey)
|
||||
|
||||
const updateIndicator = useCallback(() => {
|
||||
if (!containerRef.current || selectedIndex < 0) return
|
||||
|
||||
const targetItem = itemRefs.current[selectedIndex]
|
||||
if (!targetItem) return
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const itemRect = targetItem.getBoundingClientRect()
|
||||
const relativeLeft = itemRect.left - containerRect.left
|
||||
|
||||
setIndicatorStyle(prev => ({
|
||||
transform: `translateX(${relativeLeft}px)`,
|
||||
width: itemRect.width,
|
||||
opacity: 1,
|
||||
transition: isInitialRender.current
|
||||
? 'none'
|
||||
: prev.transition === 'none'
|
||||
? 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), width 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
: prev.transition
|
||||
}))
|
||||
|
||||
isInitialRender.current = false
|
||||
}, [selectedIndex])
|
||||
|
||||
// Update on mount, resize, and selection change
|
||||
useEffect(() => {
|
||||
updateIndicator()
|
||||
window.addEventListener('resize', updateIndicator)
|
||||
return () => window.removeEventListener('resize', updateIndicator)
|
||||
}, [updateIndicator])
|
||||
|
||||
// Watch for size changes in menu items (e.g., language switch)
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateIndicator()
|
||||
})
|
||||
|
||||
itemRefs.current.forEach(item => {
|
||||
if (item) observer.observe(item)
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [updateIndicator, items.length])
|
||||
|
||||
// Re-calculate when items labels change (language switch)
|
||||
useEffect(() => {
|
||||
// Small delay to ensure DOM has updated
|
||||
const timer = setTimeout(updateIndicator, 10)
|
||||
return () => clearTimeout(timer)
|
||||
}, [items.map(i => i.label).join(','), updateIndicator])
|
||||
|
||||
return (
|
||||
<div className={`liquid-glass-menu ${className}`} ref={containerRef}>
|
||||
<div
|
||||
className="liquid-glass-indicator"
|
||||
style={{
|
||||
transform: indicatorStyle.transform,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
opacity: indicatorStyle.opacity,
|
||||
transition: indicatorStyle.transition
|
||||
}}
|
||||
/>
|
||||
<div className="liquid-glass-items">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={el => { itemRefs.current[index] = el }}
|
||||
className={`liquid-glass-item ${item.key === selectedKey ? 'active' : ''}`}
|
||||
onClick={() => onSelect(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
95
src/client/components/ShareLinkCard.css
Normal file
95
src/client/components/ShareLinkCard.css
Normal file
@@ -0,0 +1,95 @@
|
||||
.share-link-card {
|
||||
background: var(--card-bg);
|
||||
padding: 1.25rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
margin-bottom: 2rem;
|
||||
animation: fadeInCard 0.5s ease-out;
|
||||
}
|
||||
|
||||
.share-link-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.share-link-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-link-warning {
|
||||
font-size: 0.75rem;
|
||||
color: var(--warning-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-link-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.share-link-card .share-link-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.share-link-card .share-link-input:hover,
|
||||
.share-link-card .share-link-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.share-link-copy-btn {
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--text-color);
|
||||
color: var(--background-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.share-link-copy-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.share-link-copy-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@keyframes fadeInCard {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.share-link-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.share-link-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.share-link-copy-btn {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
48
src/client/components/ShareLinkCard.tsx
Normal file
48
src/client/components/ShareLinkCard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import Toast from './Toast'
|
||||
import './ShareLinkCard.css'
|
||||
|
||||
interface ShareLinkCardProps {
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export default function ShareLinkCard({ shareUrl }: ShareLinkCardProps) {
|
||||
const { t } = useLanguage()
|
||||
const [showToast, setShowToast] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setShowToast(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="share-link-card">
|
||||
<div className="share-link-header">
|
||||
<span className="share-link-label">{t('分享链接', 'Share Link')}</span>
|
||||
<span className="share-link-warning">
|
||||
⚠️ {t('此链接随时可能失效', 'This link may expire at any time')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="share-link-row">
|
||||
<input
|
||||
type="text"
|
||||
value={shareUrl}
|
||||
readOnly
|
||||
className="share-link-input"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<button className="share-link-copy-btn" onClick={handleCopy}>
|
||||
{t('复制', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toast
|
||||
message={t('已复制到剪贴板!', 'Copied to clipboard!')}
|
||||
isVisible={showToast}
|
||||
onClose={() => setShowToast(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
165
src/client/components/ShareModal.css
Normal file
165
src/client/components/ShareModal.css
Normal file
@@ -0,0 +1,165 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
border-radius: 24px;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border-color);
|
||||
animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.share-box {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.share-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.share-input:focus, .share-input:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: var(--text-color);
|
||||
color: var(--background-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.copy-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .warning-box {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--warning-color);
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-content { padding: 1.5rem; }
|
||||
.modal-title { font-size: 1.25rem; }
|
||||
.share-box { flex-direction: column; }
|
||||
.copy-btn { padding: 12px; }
|
||||
}
|
||||
66
src/client/components/ShareModal.tsx
Normal file
66
src/client/components/ShareModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import Toast from './Toast'
|
||||
import './ShareModal.css'
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export default function ShareModal({ isOpen, onClose, shareUrl }: ShareModalProps) {
|
||||
const { t } = useLanguage()
|
||||
const [showToast, setShowToast] = useState(false)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setShowToast(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
|
||||
<div className="modal-header">
|
||||
<div className="modal-icon">🎉</div>
|
||||
<h2 className="modal-title">{t('测试完成', 'Test Completed')}</h2>
|
||||
<p className="modal-subtitle">
|
||||
{t('您的网络延迟测试已完成,分享此链接给他人查看结果。', 'Your network latency test is complete. Share this link to show results.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="share-box">
|
||||
<input
|
||||
type="text"
|
||||
value={shareUrl}
|
||||
readOnly
|
||||
className="share-input"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<button className="copy-btn" onClick={handleCopy}>
|
||||
{t('复制', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="warning-box">
|
||||
<span className="warning-icon">⚠️</span>
|
||||
<p className="warning-text">
|
||||
{t('结果链接随时可能失效,请根据需要保存测试结果。', 'Result link may expire at any time. Please save your test results as needed.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toast
|
||||
message={t('已复制到剪贴板!', 'Copied to clipboard!')}
|
||||
isVisible={showToast}
|
||||
onClose={() => setShowToast(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
src/client/components/Toast.css
Normal file
47
src/client/components/Toast.css
Normal file
@@ -0,0 +1,47 @@
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
animation: slideUpFade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='light'] .toast-content {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #1f2937;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
color: #10b981;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@keyframes slideUpFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
}
|
||||
31
src/client/components/Toast.tsx
Normal file
31
src/client/components/Toast.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react'
|
||||
import './Toast.css'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
isVisible: boolean
|
||||
onClose: () => void
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export default function Toast({ message, isVisible, onClose, duration = 3000 }: ToastProps) {
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, duration)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isVisible, duration, onClose])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div className="toast-container">
|
||||
<div className="toast-content">
|
||||
<span className="toast-icon">✓</span>
|
||||
<span className="toast-message">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('theme') as Theme
|
||||
if (saved) return saved
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
return 'dark'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -148,8 +148,185 @@ body {
|
||||
.app-footer .good { color: var(--warning-color); text-shadow: 0 0 6px var(--warning-color); }
|
||||
.app-footer .poor { color: var(--error-color); text-shadow: 0 0 6px var(--error-color); }
|
||||
|
||||
/* Share Link (shared by HomePage and ComparePage) */
|
||||
.share-link-container {
|
||||
margin-bottom: 2rem;
|
||||
background: var(--card-bg);
|
||||
padding: 1.25rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
animation: fadeInShare 0.5s ease-out;
|
||||
}
|
||||
|
||||
.share-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-link-wrapper {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.share-link-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--text-color);
|
||||
color: var(--background-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes fadeInShare {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-container .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Read-only Input */
|
||||
.readonly-input-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.readonly-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem 2rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--primary-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.readonly-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.readonly-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.readonly-badge {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Test Result Info (shown after test completion) */
|
||||
.test-result-info {
|
||||
margin-bottom: 2rem;
|
||||
animation: fadeInShare 0.5s ease-out;
|
||||
}
|
||||
|
||||
.resolved-ip-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.resolved-ip-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.resolved-ip-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header { padding: 1rem; }
|
||||
.app-title { font-size: 1.25rem; }
|
||||
.app-main { padding: 1.5rem 1rem; }
|
||||
|
||||
.readonly-input-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'
|
||||
import cors from 'cors'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import ipaddr from 'ipaddr.js'
|
||||
import { TEST_NODES } from '../shared/types'
|
||||
import { TEST_NODES, IpInfo } from '../shared/types'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3000
|
||||
@@ -58,6 +58,7 @@ interface ProbeResult {
|
||||
result: {
|
||||
status: string
|
||||
rawOutput: string
|
||||
resolvedAddress?: string
|
||||
stats?: {
|
||||
min: number
|
||||
max: number
|
||||
@@ -68,6 +69,17 @@ interface ProbeResult {
|
||||
}
|
||||
}
|
||||
|
||||
interface IpApiResponse {
|
||||
status: 'success' | 'fail'
|
||||
message?: string
|
||||
query?: string
|
||||
country?: string
|
||||
city?: string
|
||||
as?: string
|
||||
org?: string
|
||||
isp?: string
|
||||
}
|
||||
|
||||
interface MeasurementResult {
|
||||
id: string
|
||||
type: string
|
||||
@@ -146,6 +158,45 @@ function matchProbeToNode(probe: ProbeResult['probe']): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
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)$/
|
||||
|
||||
function parseAsn(value?: string): number {
|
||||
if (!value) return 0
|
||||
const match = value.match(/AS(\d+)/i)
|
||||
return match ? Number(match[1]) : 0
|
||||
}
|
||||
|
||||
async function lookupIpInfo(ip: string): Promise<IpInfo | null> {
|
||||
if (!IP_REGEX.test(ip)) return null
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,message,query,country,city,as,org,isp`,
|
||||
{ headers: { 'User-Agent': 'LatencyTest/1.0.0' } }
|
||||
)
|
||||
|
||||
if (!res.ok) return null
|
||||
|
||||
const data = (await res.json()) as IpApiResponse
|
||||
if (data.status !== 'success') {
|
||||
console.warn('IP lookup failed:', data.message || 'unknown error')
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
ip: data.query || ip,
|
||||
country: data.country || '',
|
||||
city: data.city || '',
|
||||
asn: parseAsn(data.as),
|
||||
org: data.org || '',
|
||||
isp: data.isp || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('IP lookup error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/ip', (req: Request, res: Response) => {
|
||||
const ip = extractClientIp(req)
|
||||
if (!ip) {
|
||||
@@ -194,12 +245,17 @@ app.get('/api/latency/batch/:measurementId', async (req: Request, res: Response)
|
||||
const data = await fetchRes.json() as MeasurementResult
|
||||
const results: BatchLatencyResult[] = []
|
||||
const matchedNodes = new Set<string>()
|
||||
let resolvedAddress: string | undefined
|
||||
|
||||
if (data.results) {
|
||||
for (const probeResult of data.results) {
|
||||
const result = probeResult.result
|
||||
const nodeId = matchProbeToNode(probeResult.probe)
|
||||
|
||||
if (!resolvedAddress && result.resolvedAddress) {
|
||||
resolvedAddress = result.resolvedAddress
|
||||
}
|
||||
|
||||
if (nodeId && !matchedNodes.has(nodeId)) {
|
||||
matchedNodes.add(nodeId)
|
||||
if (result.status === 'finished') {
|
||||
@@ -218,9 +274,16 @@ app.get('/api/latency/batch/:measurementId', async (req: Request, res: Response)
|
||||
}
|
||||
}
|
||||
|
||||
let ipInfo: IpInfo | null = null
|
||||
if (resolvedAddress && data.status === 'finished') {
|
||||
ipInfo = await lookupIpInfo(resolvedAddress)
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: data.status,
|
||||
results
|
||||
results,
|
||||
resolvedAddress,
|
||||
ipInfo
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get measurement error:', error)
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export interface IpInfo {
|
||||
ip: string
|
||||
country: string
|
||||
city: string
|
||||
asn: number
|
||||
org: string
|
||||
isp: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
}
|
||||
|
||||
export interface TestNode {
|
||||
id: string
|
||||
name: string
|
||||
@@ -11,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
|
||||
@@ -30,7 +99,7 @@ export const TEST_NODES: TestNode[] = [
|
||||
{ id: 'us-east', name: 'New York', region: 'North America', coords: [-74.006, 40.7128], country: 'US', city: 'New York' },
|
||||
{ id: 'us-central', name: 'Dallas', region: 'North America', coords: [-96.797, 32.7767], country: 'US', city: 'Dallas' },
|
||||
{ id: 'canada', name: 'Toronto', region: 'North America', coords: [-79.3832, 43.6532], country: 'CA', city: 'Toronto' },
|
||||
{ id: 'mexico', name: 'Mexico City', region: 'North America', coords: [-99.1332, 19.4326], country: 'MX', city: 'Mexico City' },
|
||||
{ id: 'mexico', name: 'Queretaro', region: 'North America', coords: [-100.39, 20.59], country: 'MX', city: 'Queretaro' },
|
||||
|
||||
// Europe - Western
|
||||
{ id: 'eu-west', name: 'London', region: 'Europe', coords: [-0.1276, 51.5074], country: 'GB', city: 'London' },
|
||||
|
||||
777
src/worker/index.ts
Normal file
777
src/worker/index.ts
Normal file
@@ -0,0 +1,777 @@
|
||||
import { TEST_NODES, IpInfo } from '../shared/types'
|
||||
|
||||
interface Env {
|
||||
GLOBALPING_API: string
|
||||
ASSETS?: { fetch: (request: Request) => Promise<Response> }
|
||||
RESULTS_KV?: KVNamespace
|
||||
}
|
||||
|
||||
interface MeasurementResponse {
|
||||
id: string
|
||||
probesCount: number
|
||||
}
|
||||
|
||||
interface ProbeResult {
|
||||
probe: {
|
||||
continent: string
|
||||
country: string
|
||||
city: string
|
||||
asn: number
|
||||
network: string
|
||||
}
|
||||
result: {
|
||||
status: string
|
||||
rawOutput: string
|
||||
resolvedAddress?: string
|
||||
stats?: {
|
||||
min: number
|
||||
max: number
|
||||
avg: number
|
||||
total: number
|
||||
loss: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface MeasurementResult {
|
||||
id: string
|
||||
type: string
|
||||
status: 'in-progress' | 'finished'
|
||||
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
|
||||
}
|
||||
|
||||
interface IpApiResponse {
|
||||
status: 'success' | 'fail'
|
||||
message?: string
|
||||
query?: string
|
||||
country?: string
|
||||
city?: string
|
||||
as?: string
|
||||
org?: string
|
||||
isp?: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
}
|
||||
|
||||
interface UsageState {
|
||||
totalBytes: number
|
||||
lastFullScanAt: number
|
||||
}
|
||||
|
||||
// Storage management constants
|
||||
const KV_LIMIT_BYTES = 1_000_000_000 // 1GB free-tier cap
|
||||
const KV_WARN_BYTES = Math.floor(KV_LIMIT_BYTES * 0.9) // 90% warning threshold
|
||||
const KV_TARGET_BYTES = Math.floor(KV_LIMIT_BYTES * 0.8) // 80% target after cleanup
|
||||
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',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...corsHeaders,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Generate a short ID for sharing
|
||||
function generateId(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
|
||||
let id = ''
|
||||
for (let i = 0; i < 8; i++) {
|
||||
id += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
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 DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/
|
||||
|
||||
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<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 {
|
||||
const cfConnectingIp = request.headers.get('CF-Connecting-IP')
|
||||
if (cfConnectingIp) return cfConnectingIp
|
||||
|
||||
const forwarded = request.headers.get('X-Forwarded-For')
|
||||
if (forwarded) return forwarded.split(',')[0].trim()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function createBatchMeasurement(target: string, env: Env): Promise<string> {
|
||||
const locations = TEST_NODES.map(node => ({
|
||||
country: node.country,
|
||||
city: node.city,
|
||||
limit: 1
|
||||
}))
|
||||
|
||||
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: 'ping',
|
||||
target,
|
||||
inProgressUpdates: true,
|
||||
locations,
|
||||
measurementOptions: {
|
||||
packets: 3
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({})) as { error?: { message?: string } }
|
||||
throw new Error(error.error?.message || `GlobalPing API error: ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json() as MeasurementResponse
|
||||
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 {
|
||||
if (!value) return ''
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function matchProbeToNode(probe: ProbeResult['probe']): string | null {
|
||||
const candidates = TEST_NODES.filter(node => node.country === probe.country)
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
const probeCity = normalizeLocationName(probe.city)
|
||||
if (probeCity) {
|
||||
for (const node of candidates) {
|
||||
const nodeCity = normalizeLocationName(node.city)
|
||||
if (nodeCity && (probeCity.includes(nodeCity) || nodeCity.includes(probeCity))) {
|
||||
return node.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0].id
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseAsn(value?: string): number {
|
||||
if (!value) return 0
|
||||
const match = value.match(/AS(\d+)/i)
|
||||
return match ? Number(match[1]) : 0
|
||||
}
|
||||
|
||||
async function lookupIpInfo(ip: string): Promise<IpInfo | null> {
|
||||
if (!IP_REGEX.test(ip)) return null
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`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' } }
|
||||
)
|
||||
|
||||
if (!res.ok) return null
|
||||
|
||||
const data = (await res.json()) as IpApiResponse
|
||||
if (data.status !== 'success') {
|
||||
console.warn('IP lookup failed:', data.message || 'unknown error')
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
ip: data.query || ip,
|
||||
country: data.country || '',
|
||||
city: data.city || '',
|
||||
asn: parseAsn(data.as),
|
||||
org: data.org || '',
|
||||
isp: data.isp || '',
|
||||
lat: data.lat,
|
||||
lon: data.lon
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('IP lookup error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Storage management functions
|
||||
function padTimestamp(ms: number): string {
|
||||
return ms.toString().padStart(13, '0')
|
||||
}
|
||||
|
||||
function measureBytes(value: string): number {
|
||||
return new TextEncoder().encode(value).length
|
||||
}
|
||||
|
||||
async function getUsage(kv: KVNamespace): Promise<UsageState> {
|
||||
const data = await kv.get('result:usage', 'json') as Partial<UsageState> | null
|
||||
return {
|
||||
totalBytes: data?.totalBytes ?? 0,
|
||||
lastFullScanAt: data?.lastFullScanAt ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
async function putUsage(kv: KVNamespace, usage: UsageState): Promise<void> {
|
||||
await kv.put('result:usage', JSON.stringify(usage))
|
||||
}
|
||||
|
||||
async function recountUsage(kv: KVNamespace): Promise<UsageState> {
|
||||
let totalBytes = 0
|
||||
let cursor: string | undefined
|
||||
|
||||
do {
|
||||
const res = await kv.list({ prefix: 'result:index:', limit: 1000, cursor })
|
||||
for (const key of res.keys) {
|
||||
const meta = key.metadata as { size?: number } | undefined
|
||||
totalBytes += meta?.size ?? 0
|
||||
}
|
||||
cursor = res.list_complete ? undefined : res.cursor
|
||||
} while (cursor)
|
||||
|
||||
const usage = { totalBytes, lastFullScanAt: Date.now() }
|
||||
await putUsage(kv, usage)
|
||||
return usage
|
||||
}
|
||||
|
||||
async function cleanupOldest(kv: KVNamespace, usage: UsageState, incomingBytes: number): Promise<UsageState> {
|
||||
let totalBytes = usage.totalBytes
|
||||
let cursor: string | undefined
|
||||
|
||||
while (totalBytes + incomingBytes > KV_TARGET_BYTES) {
|
||||
const res = await kv.list({ prefix: 'result:index:', limit: 100, cursor })
|
||||
if (res.keys.length === 0) break
|
||||
|
||||
for (const key of res.keys) {
|
||||
if (totalBytes + incomingBytes <= KV_TARGET_BYTES) break
|
||||
|
||||
const meta = key.metadata as { size?: number } | undefined
|
||||
const size = meta?.size ?? 0
|
||||
const parts = key.name.split(':')
|
||||
const resultId = parts[parts.length - 1]
|
||||
|
||||
await Promise.all([
|
||||
kv.delete(key.name),
|
||||
kv.delete(`result:${resultId}`)
|
||||
])
|
||||
|
||||
totalBytes = Math.max(0, totalBytes - size)
|
||||
}
|
||||
|
||||
cursor = res.list_complete ? undefined : res.cursor
|
||||
if (!cursor) break
|
||||
}
|
||||
|
||||
const nextUsage = { totalBytes, lastFullScanAt: Date.now() }
|
||||
await putUsage(kv, nextUsage)
|
||||
return nextUsage
|
||||
}
|
||||
|
||||
async function ensureCapacity(kv: KVNamespace, incomingBytes: number): Promise<UsageState | null> {
|
||||
let usage = await getUsage(kv)
|
||||
const now = Date.now()
|
||||
|
||||
// Recount if stale or approaching limit
|
||||
if ((now - usage.lastFullScanAt > FULL_SCAN_INTERVAL_MS) || (usage.totalBytes + incomingBytes > KV_WARN_BYTES)) {
|
||||
usage = await recountUsage(kv)
|
||||
}
|
||||
|
||||
// Cleanup if still above warning threshold
|
||||
if (usage.totalBytes + incomingBytes > KV_WARN_BYTES) {
|
||||
usage = await cleanupOldest(kv, usage, incomingBytes)
|
||||
}
|
||||
|
||||
// Reject write if still over limit after cleanup
|
||||
if (usage.totalBytes + incomingBytes > KV_LIMIT_BYTES) {
|
||||
return null
|
||||
}
|
||||
|
||||
return usage
|
||||
}
|
||||
|
||||
async function handleGetIp(request: Request): Promise<Response> {
|
||||
const ip = extractClientIp(request)
|
||||
if (!ip) {
|
||||
return jsonResponse({ error: 'Unable to determine IP' }, 500)
|
||||
}
|
||||
return jsonResponse({ ip })
|
||||
}
|
||||
|
||||
async function handleCreateMeasurement(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
const body = await request.json() as { target?: string }
|
||||
const { target } = body
|
||||
|
||||
if (!target || typeof target !== 'string') {
|
||||
return jsonResponse({ error: 'target is required' }, 400)
|
||||
}
|
||||
|
||||
const trimmedTarget = target.trim().toLowerCase()
|
||||
|
||||
if (!isValidTarget(trimmedTarget)) {
|
||||
return jsonResponse({ error: 'Invalid target. Please enter a valid IP address or domain name.' }, 400)
|
||||
}
|
||||
|
||||
// Resolve domain to IP for traceroute (GlobalPing requires IP)
|
||||
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) {
|
||||
console.error('Batch measurement creation error:', error)
|
||||
return jsonResponse({ error: 'Failed to create measurement' }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetMeasurement(measurementId: string, env: Env): Promise<Response> {
|
||||
try {
|
||||
const fetchRes = await fetch(`${env.GLOBALPING_API}/measurements/${measurementId}`, {
|
||||
headers: {
|
||||
'User-Agent': 'LatencyTest/1.0.0'
|
||||
}
|
||||
})
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
return jsonResponse({ error: 'Failed to get measurement' }, fetchRes.status)
|
||||
}
|
||||
|
||||
const data = await fetchRes.json() as MeasurementResult
|
||||
const results: BatchLatencyResult[] = []
|
||||
const matchedNodes = new Set<string>()
|
||||
let resolvedAddress: string | undefined
|
||||
|
||||
if (data.results) {
|
||||
for (const probeResult of data.results) {
|
||||
const result = probeResult.result
|
||||
const nodeId = matchProbeToNode(probeResult.probe)
|
||||
|
||||
// Capture resolved IP from first successful result
|
||||
if (!resolvedAddress && result.resolvedAddress) {
|
||||
resolvedAddress = result.resolvedAddress
|
||||
}
|
||||
|
||||
if (nodeId && !matchedNodes.has(nodeId)) {
|
||||
matchedNodes.add(nodeId)
|
||||
if (result.status === 'finished' && result.stats) {
|
||||
const latency = result.stats.avg != null ? Math.round(result.stats.avg) : 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 {
|
||||
results.push({ nodeId, latency: null, success: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of TEST_NODES) {
|
||||
if (!matchedNodes.has(node.id)) {
|
||||
results.push({ nodeId: node.id, latency: null, success: false })
|
||||
}
|
||||
}
|
||||
|
||||
let ipInfo: IpInfo | null = null
|
||||
if (resolvedAddress && data.status === 'finished') {
|
||||
ipInfo = await lookupIpInfo(resolvedAddress)
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
status: data.status,
|
||||
results,
|
||||
resolvedAddress,
|
||||
ipInfo
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get measurement error:', error)
|
||||
return jsonResponse({ error: 'Failed to get measurement' }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveResult(request: Request, env: Env): Promise<Response> {
|
||||
if (!env.RESULTS_KV) {
|
||||
return jsonResponse({ error: 'Storage not configured' }, 500)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
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
|
||||
}
|
||||
|
||||
if (!body.type || !['single', 'compare'].includes(body.type)) {
|
||||
return jsonResponse({ error: 'Invalid type' }, 400)
|
||||
}
|
||||
|
||||
if (!body.input || !body.results) {
|
||||
return jsonResponse({ error: 'Missing input or results' }, 400)
|
||||
}
|
||||
|
||||
const id = generateId()
|
||||
const savedResult: SavedResult = {
|
||||
type: body.type,
|
||||
input: body.input,
|
||||
results: body.results,
|
||||
traceroute: body.traceroute ?? null,
|
||||
ipInfo: body.ipInfo ?? null,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(savedResult)
|
||||
const sizeBytes = measureBytes(serialized)
|
||||
const createdMs = Date.now()
|
||||
const indexKey = `result:index:${padTimestamp(createdMs)}:${id}`
|
||||
|
||||
// Ensure there is room before writing
|
||||
const usage = await ensureCapacity(env.RESULTS_KV, sizeBytes)
|
||||
|
||||
// Reject if storage is full
|
||||
if (!usage) {
|
||||
return jsonResponse({ error: 'Storage is full. Please try again later.' }, 503)
|
||||
}
|
||||
|
||||
// Store with TTL (data + index). Index carries size metadata for cleanup
|
||||
await Promise.all([
|
||||
env.RESULTS_KV.put(`result:${id}`, serialized, { expirationTtl: RESULT_TTL_SECONDS }),
|
||||
env.RESULTS_KV.put(indexKey, '', { expirationTtl: RESULT_TTL_SECONDS, metadata: { size: sizeBytes } }),
|
||||
putUsage(env.RESULTS_KV, { totalBytes: usage.totalBytes + sizeBytes, lastFullScanAt: usage.lastFullScanAt })
|
||||
])
|
||||
|
||||
const url = new URL(request.url)
|
||||
const shareUrl = body.type === 'compare'
|
||||
? `${url.origin}/compare/result/${id}`
|
||||
: `${url.origin}/result/${id}`
|
||||
|
||||
return jsonResponse({ id, shareUrl })
|
||||
} catch (error) {
|
||||
console.error('Save result error:', error)
|
||||
return jsonResponse({ error: 'Failed to save result' }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetResult(id: string, env: Env): Promise<Response> {
|
||||
if (!env.RESULTS_KV) {
|
||||
return jsonResponse({ error: 'Storage not configured' }, 500)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await env.RESULTS_KV.get(`result:${id}`, 'json') as SavedResult | null
|
||||
|
||||
if (!data) {
|
||||
return jsonResponse({ error: 'Result not found' }, 404)
|
||||
}
|
||||
|
||||
return jsonResponse(data)
|
||||
} catch (error) {
|
||||
console.error('Get result error:', error)
|
||||
return jsonResponse({ error: 'Failed to get result' }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
const path = url.pathname
|
||||
|
||||
// Handle CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// API routes
|
||||
if (path === '/api/ip' && request.method === 'GET') {
|
||||
return handleGetIp(request)
|
||||
}
|
||||
|
||||
if (path === '/api/latency/batch' && request.method === 'POST') {
|
||||
return handleCreateMeasurement(request, env)
|
||||
}
|
||||
|
||||
const batchMatch = path.match(/^\/api\/latency\/batch\/([a-zA-Z0-9-]+)$/)
|
||||
if (batchMatch && request.method === 'GET') {
|
||||
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)
|
||||
}
|
||||
|
||||
const resultMatch = path.match(/^\/api\/results\/([a-zA-Z0-9]+)$/)
|
||||
if (resultMatch && request.method === 'GET') {
|
||||
return handleGetResult(resultMatch[1], env)
|
||||
}
|
||||
|
||||
// For non-API routes, let Cloudflare Assets handle static files
|
||||
// This will be caught by the assets handler configured in wrangler.toml
|
||||
return env.ASSETS ? env.ASSETS.fetch(request) : new Response('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
20
tsconfig.worker.json
Normal file
20
tsconfig.worker.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/worker/**/*", "src/shared/**/*"]
|
||||
}
|
||||
@@ -1,14 +1,65 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
// SPA fallback middleware - runs after Vite's built-in middleware
|
||||
function spaFallback() {
|
||||
return {
|
||||
name: 'spa-fallback',
|
||||
configureServer: function (server) {
|
||||
// Return a function to execute after Vite's internal middleware
|
||||
return function () {
|
||||
server.middlewares.use(function (req, res, next) {
|
||||
var _a, _b;
|
||||
// Skip API routes and file requests
|
||||
if (((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith('/api')) || ((_b = req.url) === null || _b === void 0 ? void 0 : _b.includes('.'))) {
|
||||
return next();
|
||||
}
|
||||
// Serve index.html for SPA routes
|
||||
var indexPath = path.resolve(__dirname, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
var html = fs.readFileSync(indexPath, 'utf-8');
|
||||
server.transformIndexHtml(req.url || '/', html).then(function (transformed) {
|
||||
res.end(transformed);
|
||||
}).catch(next);
|
||||
}
|
||||
else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
configurePreviewServer: function (server) {
|
||||
// Same logic for preview server
|
||||
server.middlewares.use(function (req, res, next) {
|
||||
var _a, _b;
|
||||
if (((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith('/api')) || ((_b = req.url) === null || _b === void 0 ? void 0 : _b.includes('.'))) {
|
||||
return next();
|
||||
}
|
||||
var indexPath = path.resolve(__dirname, 'dist/client/index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(fs.readFileSync(indexPath, 'utf-8'));
|
||||
}
|
||||
else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), spaFallback()],
|
||||
root: '.',
|
||||
publicDir: 'public',
|
||||
build: {
|
||||
outDir: 'dist/client',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
@@ -16,6 +67,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/client'),
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type PluginOption } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
// SPA fallback middleware - runs after Vite's built-in middleware
|
||||
function spaFallback(): PluginOption {
|
||||
return {
|
||||
name: 'spa-fallback',
|
||||
configureServer(server) {
|
||||
// Return a function to execute after Vite's internal middleware
|
||||
return () => {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
// Skip API routes and file requests
|
||||
if (req.url?.startsWith('/api') || req.url?.includes('.')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// Serve index.html for SPA routes
|
||||
const indexPath = path.resolve(__dirname, 'index.html')
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
let html = fs.readFileSync(indexPath, 'utf-8')
|
||||
server.transformIndexHtml(req.url || '/', html).then(transformed => {
|
||||
res.end(transformed)
|
||||
}).catch(next)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
// Same logic for preview server
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url?.startsWith('/api') || req.url?.includes('.')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const indexPath = path.resolve(__dirname, 'dist/client/index.html')
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end(fs.readFileSync(indexPath, 'utf-8'))
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), spaFallback()],
|
||||
root: '.',
|
||||
publicDir: 'public',
|
||||
build: {
|
||||
outDir: 'dist/client',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
@@ -17,6 +67,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src/client'),
|
||||
|
||||
17
wrangler.toml
Normal file
17
wrangler.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
name = "latency-test"
|
||||
main = "dist/worker/index.js"
|
||||
compatibility_date = "2025-12-25"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[assets]
|
||||
directory = "./dist/client"
|
||||
html_handling = "auto-trailing-slash"
|
||||
not_found_handling = "single-page-application"
|
||||
|
||||
[vars]
|
||||
GLOBALPING_API = "https://api.globalping.io/v1"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "RESULTS_KV"
|
||||
id = "8c7f404f066a4610bc8bab96e076295c"
|
||||
preview_id = "8c7f404f066a4610bc8bab96e076295c"
|
||||
Reference in New Issue
Block a user