feat(record): 增加测试结果保存和展示功能
This commit is contained in:
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📸 截图
|
||||||
|
|
||||||
|
> *截图待添加*
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
待定
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ function AppRoutes() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/result/:id" element={<HomePage />} />
|
||||||
<Route path="/compare" element={<ComparePage />} />
|
<Route path="/compare" element={<ComparePage />} />
|
||||||
|
<Route path="/compare/result/:id" element={<ComparePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LatencyResult, TEST_NODES } from '@shared/types'
|
import { LatencyResult, TEST_NODES, IpInfo } from '@shared/types'
|
||||||
|
|
||||||
const API_BASE = '/api'
|
const API_BASE = '/api'
|
||||||
|
|
||||||
@@ -17,6 +17,34 @@ export interface BatchResultResponse {
|
|||||||
latency: number | null
|
latency: number | null
|
||||||
success: boolean
|
success: boolean
|
||||||
}>
|
}>
|
||||||
|
resolvedAddress?: string
|
||||||
|
ipInfo?: IpInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveResultRequest {
|
||||||
|
type: 'single' | 'compare'
|
||||||
|
input: { target: string } | { leftTarget: string; rightTarget: string }
|
||||||
|
results: Array<{ nodeId: string; latency: number | null; success: boolean }> | {
|
||||||
|
left: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
||||||
|
right: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
||||||
|
}
|
||||||
|
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveResultResponse {
|
||||||
|
id: string
|
||||||
|
shareUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedResultData {
|
||||||
|
type: 'single' | 'compare'
|
||||||
|
input: { target: string } | { leftTarget: string; rightTarget: string }
|
||||||
|
results: Array<{ nodeId: string; latency: number | null; success: boolean }> | {
|
||||||
|
left: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
||||||
|
right: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
||||||
|
}
|
||||||
|
ipInfo?: IpInfo | { left: IpInfo | null; right: IpInfo | null } | null
|
||||||
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUserIp(): Promise<string> {
|
export async function fetchUserIp(): Promise<string> {
|
||||||
@@ -26,10 +54,15 @@ export async function fetchUserIp(): Promise<string> {
|
|||||||
return data.ip
|
return data.ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
resolvedAddress?: string
|
||||||
|
ipInfo?: IpInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
export async function testAllNodes(
|
export async function testAllNodes(
|
||||||
target: string,
|
target: string,
|
||||||
onProgress: (result: LatencyResult) => void
|
onProgress: (result: LatencyResult) => void
|
||||||
): Promise<void> {
|
): Promise<TestResult> {
|
||||||
for (const node of TEST_NODES) {
|
for (const node of TEST_NODES) {
|
||||||
onProgress({ nodeId: node.id, latency: null, status: 'pending' })
|
onProgress({ nodeId: node.id, latency: null, status: 'pending' })
|
||||||
}
|
}
|
||||||
@@ -44,7 +77,7 @@ export async function testAllNodes(
|
|||||||
for (const node of TEST_NODES) {
|
for (const node of TEST_NODES) {
|
||||||
onProgress({ nodeId: node.id, latency: null, status: 'failed' })
|
onProgress({ nodeId: node.id, latency: null, status: 'failed' })
|
||||||
}
|
}
|
||||||
return
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { measurementId }: BatchMeasurementResponse = await res.json()
|
const { measurementId }: BatchMeasurementResponse = await res.json()
|
||||||
@@ -56,6 +89,8 @@ export async function testAllNodes(
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const timeout = 60000
|
const timeout = 60000
|
||||||
const completedNodes = new Set<string>()
|
const completedNodes = new Set<string>()
|
||||||
|
let resolvedAddress: string | undefined
|
||||||
|
let ipInfo: IpInfo | null | undefined
|
||||||
|
|
||||||
while (Date.now() - startTime < timeout) {
|
while (Date.now() - startTime < timeout) {
|
||||||
await new Promise(r => setTimeout(r, 800))
|
await new Promise(r => setTimeout(r, 800))
|
||||||
@@ -65,6 +100,16 @@ export async function testAllNodes(
|
|||||||
|
|
||||||
const data: BatchResultResponse = await pollRes.json()
|
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) {
|
for (const result of data.results) {
|
||||||
if (result.success && !completedNodes.has(result.nodeId)) {
|
if (result.success && !completedNodes.has(result.nodeId)) {
|
||||||
completedNodes.add(result.nodeId)
|
completedNodes.add(result.nodeId)
|
||||||
@@ -89,4 +134,30 @@ export async function testAllNodes(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { resolvedAddress, ipInfo }
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,12 +291,127 @@
|
|||||||
color: var(--success-color);
|
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-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) {
|
@media (max-width: 768px) {
|
||||||
.compare-inputs {
|
.compare-inputs {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 1rem;
|
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 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.results-table th,
|
.results-table th,
|
||||||
.results-table td {
|
.results-table td {
|
||||||
padding: 0.625rem 0.75rem;
|
padding: 0.625rem 0.75rem;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { testAllNodes } from '../api/latency'
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { LatencyResult, TEST_NODES } from '@shared/types'
|
import { LatencyResult, TEST_NODES, IpInfo } from '@shared/types'
|
||||||
|
import ShareModal from './ShareModal'
|
||||||
|
import ExpirationBanner from './ExpirationBanner'
|
||||||
|
import ShareLinkCard from './ShareLinkCard'
|
||||||
|
import IpInfoCard from './IpInfoCard'
|
||||||
import './ComparePage.css'
|
import './ComparePage.css'
|
||||||
|
|
||||||
const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/
|
const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/
|
||||||
@@ -13,17 +18,104 @@ function isValidTarget(value: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ComparePage() {
|
export default function ComparePage() {
|
||||||
|
const { id: resultId } = useParams<{ id: string }>()
|
||||||
|
const isReadOnly = !!resultId
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const [targetA, setTargetA] = useState('')
|
const [targetA, setTargetA] = useState('')
|
||||||
const [targetB, setTargetB] = useState('')
|
const [targetB, setTargetB] = useState('')
|
||||||
const [resultsA, setResultsA] = useState<Map<string, LatencyResult>>(new Map())
|
const [resultsA, setResultsA] = useState<Map<string, LatencyResult>>(new Map())
|
||||||
const [resultsB, setResultsB] = useState<Map<string, LatencyResult>>(new Map())
|
const [resultsB, setResultsB] = useState<Map<string, LatencyResult>>(new Map())
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [errors, setErrors] = useState({ a: '', b: '' })
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 }>
|
||||||
|
right: Array<{ nodeId: string; latency: number | null; success: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of savedResults.right) {
|
||||||
|
rightMap.set(r.nodeId, {
|
||||||
|
nodeId: r.nodeId,
|
||||||
|
latency: r.latency,
|
||||||
|
status: r.success ? 'success' : 'failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoadError(t('无法加载测试结果', 'Failed to load test result'))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [resultId, t])
|
||||||
|
|
||||||
const handleCompare = async () => {
|
const handleCompare = async () => {
|
||||||
const aValid = isValidTarget(targetA)
|
if (isReadOnly) return
|
||||||
const bValid = isValidTarget(targetB)
|
|
||||||
|
const trimmedA = targetA.trim()
|
||||||
|
const trimmedB = targetB.trim()
|
||||||
|
const aValid = isValidTarget(trimmedA)
|
||||||
|
const bValid = isValidTarget(trimmedB)
|
||||||
|
|
||||||
setErrors({
|
setErrors({
|
||||||
a: aValid ? '' : t('无效的目标', 'Invalid target'),
|
a: aValid ? '' : t('无效的目标', 'Invalid target'),
|
||||||
@@ -35,16 +127,72 @@ export default function ComparePage() {
|
|||||||
setTesting(true)
|
setTesting(true)
|
||||||
setResultsA(new Map())
|
setResultsA(new Map())
|
||||||
setResultsB(new Map())
|
setResultsB(new Map())
|
||||||
|
setShareUrl(null)
|
||||||
|
setResolvedIpA(null)
|
||||||
|
setResolvedIpB(null)
|
||||||
|
setIpInfoA(null)
|
||||||
|
setIpInfoB(null)
|
||||||
|
|
||||||
|
const leftResults = new Map<string, { nodeId: string; latency: number | null; success: boolean }>()
|
||||||
|
const rightResults = new Map<string, { nodeId: string; latency: number | null; success: boolean }>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
const [resA, resB] = await Promise.all([
|
||||||
testAllNodes(targetA.trim(), (res) => {
|
testAllNodes(trimmedA, (res) => {
|
||||||
setResultsA(prev => new Map(prev).set(res.nodeId, 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
testAllNodes(targetB.trim(), (res) => {
|
testAllNodes(trimmedB, (res) => {
|
||||||
setResultsB(prev => new Map(prev).set(res.nodeId, 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if (resA.resolvedAddress) {
|
||||||
|
setResolvedIpA(resA.resolvedAddress)
|
||||||
|
}
|
||||||
|
if (resB.resolvedAddress) {
|
||||||
|
setResolvedIpB(resB.resolvedAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resA.ipInfo) {
|
||||||
|
setIpInfoA(resA.ipInfo)
|
||||||
|
}
|
||||||
|
if (resB.ipInfo) {
|
||||||
|
setIpInfoB(resB.ipInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
setShareUrl(url)
|
||||||
|
setShowShareModal(true)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save result:', e)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Comparison test failed:', e)
|
console.error('Comparison test failed:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,6 +236,25 @@ export default function ComparePage() {
|
|||||||
const summary = calculateSummary()
|
const summary = calculateSummary()
|
||||||
const hasResults = resultsA.size > 0 || resultsB.size > 0
|
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 (
|
return (
|
||||||
<div className="compare-page">
|
<div className="compare-page">
|
||||||
<div className="compare-header">
|
<div className="compare-header">
|
||||||
@@ -97,47 +264,80 @@ export default function ComparePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="compare-inputs">
|
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
||||||
<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">
|
{isReadOnly ? (
|
||||||
<button
|
<div className="compare-inputs readonly">
|
||||||
className="compare-button"
|
<div className="readonly-input-group">
|
||||||
onClick={handleCompare}
|
<span className="readonly-label">{t('目标 A', 'Target A')}</span>
|
||||||
disabled={testing || !targetA.trim() || !targetB.trim()}
|
<span className="readonly-value">{targetA}</span>
|
||||||
>
|
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
|
||||||
{testing ? (
|
</div>
|
||||||
<>
|
<div className="readonly-vs">VS</div>
|
||||||
<span className="spinner" />
|
<div className="readonly-input-group">
|
||||||
{t('测试中...', 'Testing...')}
|
<span className="readonly-label">{t('目标 B', 'Target B')}</span>
|
||||||
</>
|
<span className="readonly-value">{targetB}</span>
|
||||||
) : (
|
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
||||||
t('开始对比', 'Start Comparison')
|
</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 && !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>
|
||||||
|
)}
|
||||||
|
<ShareLinkCard shareUrl={shareUrl} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{hasResults && (
|
{hasResults && (
|
||||||
<>
|
<>
|
||||||
@@ -146,8 +346,22 @@ export default function ComparePage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t('节点', 'Node')}</th>
|
<th>{t('节点', 'Node')}</th>
|
||||||
<th>{targetA || 'A'}</th>
|
<th>
|
||||||
<th>{targetB || 'B'}</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>
|
<th>{t('差值', 'Diff')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -221,6 +435,12 @@ export default function ComparePage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ShareModal
|
||||||
|
isOpen={showShareModal}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
shareUrl={shareUrl || ''}
|
||||||
|
/>
|
||||||
</div>
|
</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,26 +1,116 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
import IpInput from './IpInput'
|
import IpInput from './IpInput'
|
||||||
import LatencyMap from './LatencyMap'
|
import LatencyMap from './LatencyMap'
|
||||||
import ResultsPanel from './ResultsPanel'
|
import ResultsPanel from './ResultsPanel'
|
||||||
import { testAllNodes } from '../api/latency'
|
import ShareModal from './ShareModal'
|
||||||
import { LatencyResult } from '@shared/types'
|
import ExpirationBanner from './ExpirationBanner'
|
||||||
|
import ShareLinkCard from './ShareLinkCard'
|
||||||
|
import IpInfoCard from './IpInfoCard'
|
||||||
|
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
|
||||||
|
import { LatencyResult, IpInfo } from '@shared/types'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const { id: resultId } = useParams<{ id: string }>()
|
||||||
|
const isReadOnly = !!resultId
|
||||||
|
|
||||||
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
const [results, setResults] = useState<Map<string, LatencyResult>>(new Map())
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
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 { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const handleTest = useCallback(async (target: string) => {
|
// 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 }>) {
|
||||||
|
resultsMap.set(r.nodeId, {
|
||||||
|
nodeId: r.nodeId,
|
||||||
|
latency: r.latency,
|
||||||
|
status: r.success ? 'success' : 'failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setResults(resultsMap)
|
||||||
|
|
||||||
|
// Load saved IP info for readonly view
|
||||||
|
if (data.ipInfo && 'ip' in data.ipInfo) {
|
||||||
|
setIpInfo(data.ipInfo as IpInfo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError(t('无法加载测试结果', 'Failed to load test result'))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [resultId, t])
|
||||||
|
|
||||||
|
const handleTest = useCallback(async (testTarget: string) => {
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
setResults(new Map())
|
setResults(new Map())
|
||||||
setSelectedNodeId(null)
|
setSelectedNodeId(null)
|
||||||
|
setShareUrl(null)
|
||||||
|
setResolvedIp(null)
|
||||||
|
setIpInfo(null)
|
||||||
|
setTarget(testTarget)
|
||||||
|
|
||||||
await testAllNodes(target, (result) => {
|
const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean }> = []
|
||||||
|
|
||||||
|
const { resolvedAddress, ipInfo: fetchedIpInfo } = await testAllNodes(testTarget, (result) => {
|
||||||
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
||||||
|
if (result.status === 'success' || result.status === 'failed') {
|
||||||
|
finalResults.push({
|
||||||
|
nodeId: result.nodeId,
|
||||||
|
latency: result.latency,
|
||||||
|
success: result.status === 'success'
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (resolvedAddress) {
|
||||||
|
setResolvedIp(resolvedAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchedIpInfo) {
|
||||||
|
setIpInfo(fetchedIpInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save result and get share URL
|
||||||
|
try {
|
||||||
|
const { shareUrl: url } = await saveResult({
|
||||||
|
type: 'single',
|
||||||
|
input: { target: testTarget },
|
||||||
|
results: finalResults,
|
||||||
|
ipInfo: fetchedIpInfo
|
||||||
|
})
|
||||||
|
setShareUrl(url)
|
||||||
|
setShowShareModal(true)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save result:', e)
|
||||||
|
}
|
||||||
|
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -28,12 +118,61 @@ export default function HomePage() {
|
|||||||
setSelectedNodeId(nodeId)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="app-description">
|
<p className="app-description">
|
||||||
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
||||||
</p>
|
</p>
|
||||||
<IpInput onTest={handleTest} testing={testing} />
|
|
||||||
|
{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 />}
|
||||||
|
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IpInput onTest={handleTest} testing={testing} />
|
||||||
|
{(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl) && (
|
||||||
|
<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}
|
||||||
|
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<LatencyMap
|
<LatencyMap
|
||||||
results={results}
|
results={results}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
@@ -44,6 +183,12 @@ export default function HomePage() {
|
|||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
onNodeSelect={handleNodeSelect}
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -148,8 +148,185 @@ body {
|
|||||||
.app-footer .good { color: var(--warning-color); text-shadow: 0 0 6px var(--warning-color); }
|
.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); }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.app-header { padding: 1rem; }
|
.app-header { padding: 1rem; }
|
||||||
.app-title { font-size: 1.25rem; }
|
.app-title { font-size: 1.25rem; }
|
||||||
.app-main { padding: 1.5rem 1rem; }
|
.app-main { padding: 1.5rem 1rem; }
|
||||||
|
|
||||||
|
.readonly-input-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'
|
|||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import rateLimit from 'express-rate-limit'
|
import rateLimit from 'express-rate-limit'
|
||||||
import ipaddr from 'ipaddr.js'
|
import ipaddr from 'ipaddr.js'
|
||||||
import { TEST_NODES } from '../shared/types'
|
import { TEST_NODES, IpInfo } from '../shared/types'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT || 3000
|
const PORT = process.env.PORT || 3000
|
||||||
@@ -58,6 +58,7 @@ interface ProbeResult {
|
|||||||
result: {
|
result: {
|
||||||
status: string
|
status: string
|
||||||
rawOutput: string
|
rawOutput: string
|
||||||
|
resolvedAddress?: string
|
||||||
stats?: {
|
stats?: {
|
||||||
min: number
|
min: number
|
||||||
max: 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 {
|
interface MeasurementResult {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
type: string
|
||||||
@@ -146,6 +158,45 @@ function matchProbeToNode(probe: ProbeResult['probe']): string | null {
|
|||||||
return 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) => {
|
app.get('/api/ip', (req: Request, res: Response) => {
|
||||||
const ip = extractClientIp(req)
|
const ip = extractClientIp(req)
|
||||||
if (!ip) {
|
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 data = await fetchRes.json() as MeasurementResult
|
||||||
const results: BatchLatencyResult[] = []
|
const results: BatchLatencyResult[] = []
|
||||||
const matchedNodes = new Set<string>()
|
const matchedNodes = new Set<string>()
|
||||||
|
let resolvedAddress: string | undefined
|
||||||
|
|
||||||
if (data.results) {
|
if (data.results) {
|
||||||
for (const probeResult of data.results) {
|
for (const probeResult of data.results) {
|
||||||
const result = probeResult.result
|
const result = probeResult.result
|
||||||
const nodeId = matchProbeToNode(probeResult.probe)
|
const nodeId = matchProbeToNode(probeResult.probe)
|
||||||
|
|
||||||
|
if (!resolvedAddress && result.resolvedAddress) {
|
||||||
|
resolvedAddress = result.resolvedAddress
|
||||||
|
}
|
||||||
|
|
||||||
if (nodeId && !matchedNodes.has(nodeId)) {
|
if (nodeId && !matchedNodes.has(nodeId)) {
|
||||||
matchedNodes.add(nodeId)
|
matchedNodes.add(nodeId)
|
||||||
if (result.status === 'finished') {
|
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({
|
res.json({
|
||||||
status: data.status,
|
status: data.status,
|
||||||
results
|
results,
|
||||||
|
resolvedAddress,
|
||||||
|
ipInfo
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get measurement error:', error)
|
console.error('Get measurement error:', error)
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
export interface IpInfo {
|
||||||
|
ip: string
|
||||||
|
country: string
|
||||||
|
city: string
|
||||||
|
asn: number
|
||||||
|
org: string
|
||||||
|
isp: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TestNode {
|
export interface TestNode {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TEST_NODES } from '../shared/types'
|
import { TEST_NODES, IpInfo } from '../shared/types'
|
||||||
|
|
||||||
interface Env {
|
interface Env {
|
||||||
GLOBALPING_API: string
|
GLOBALPING_API: string
|
||||||
ASSETS?: { fetch: (request: Request) => Promise<Response> }
|
ASSETS?: { fetch: (request: Request) => Promise<Response> }
|
||||||
|
RESULTS_KV?: KVNamespace
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeasurementResponse {
|
interface MeasurementResponse {
|
||||||
@@ -21,6 +22,7 @@ interface ProbeResult {
|
|||||||
result: {
|
result: {
|
||||||
status: string
|
status: string
|
||||||
rawOutput: string
|
rawOutput: string
|
||||||
|
resolvedAddress?: string
|
||||||
stats?: {
|
stats?: {
|
||||||
min: number
|
min: number
|
||||||
max: number
|
max: number
|
||||||
@@ -44,6 +46,37 @@ interface BatchLatencyResult {
|
|||||||
success: boolean
|
success: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SavedResult {
|
||||||
|
type: 'single' | 'compare'
|
||||||
|
input: { target: string } | { leftTarget: string; rightTarget: string }
|
||||||
|
results: BatchLatencyResult[] | { left: BatchLatencyResult[]; right: BatchLatencyResult[] }
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
@@ -60,6 +93,16 @@ function jsonResponse(data: unknown, status = 200): Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 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,}$/
|
const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/
|
||||||
|
|
||||||
@@ -141,6 +184,137 @@ function matchProbeToNode(probe: ProbeResult['probe']): string | null {
|
|||||||
return null
|
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`,
|
||||||
|
{ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> {
|
async function handleGetIp(request: Request): Promise<Response> {
|
||||||
const ip = extractClientIp(request)
|
const ip = extractClientIp(request)
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
@@ -187,12 +361,18 @@ async function handleGetMeasurement(measurementId: string, env: Env): Promise<Re
|
|||||||
const data = await fetchRes.json() as MeasurementResult
|
const data = await fetchRes.json() as MeasurementResult
|
||||||
const results: BatchLatencyResult[] = []
|
const results: BatchLatencyResult[] = []
|
||||||
const matchedNodes = new Set<string>()
|
const matchedNodes = new Set<string>()
|
||||||
|
let resolvedAddress: string | undefined
|
||||||
|
|
||||||
if (data.results) {
|
if (data.results) {
|
||||||
for (const probeResult of data.results) {
|
for (const probeResult of data.results) {
|
||||||
const result = probeResult.result
|
const result = probeResult.result
|
||||||
const nodeId = matchProbeToNode(probeResult.probe)
|
const nodeId = matchProbeToNode(probeResult.probe)
|
||||||
|
|
||||||
|
// Capture resolved IP from first successful result
|
||||||
|
if (!resolvedAddress && result.resolvedAddress) {
|
||||||
|
resolvedAddress = result.resolvedAddress
|
||||||
|
}
|
||||||
|
|
||||||
if (nodeId && !matchedNodes.has(nodeId)) {
|
if (nodeId && !matchedNodes.has(nodeId)) {
|
||||||
matchedNodes.add(nodeId)
|
matchedNodes.add(nodeId)
|
||||||
if (result.status === 'finished') {
|
if (result.status === 'finished') {
|
||||||
@@ -211,9 +391,16 @@ async function handleGetMeasurement(measurementId: string, env: Env): Promise<Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ipInfo: IpInfo | null = null
|
||||||
|
if (resolvedAddress && data.status === 'finished') {
|
||||||
|
ipInfo = await lookupIpInfo(resolvedAddress)
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
status: data.status,
|
status: data.status,
|
||||||
results
|
results,
|
||||||
|
resolvedAddress,
|
||||||
|
ipInfo
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get measurement error:', error)
|
console.error('Get measurement error:', error)
|
||||||
@@ -221,6 +408,87 @@ async function handleGetMeasurement(measurementId: string, env: Env): Promise<Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[] }
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
@@ -245,6 +513,16 @@ export default {
|
|||||||
return handleGetMeasurement(batchMatch[1], env)
|
return handleGetMeasurement(batchMatch[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
|
// For non-API routes, let Cloudflare Assets handle static files
|
||||||
// This will be caught by the assets handler configured in wrangler.toml
|
// 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 })
|
return env.ASSETS ? env.ASSETS.fetch(request) : new Response('Not Found', { status: 404 })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name = "latency-test"
|
name = "latency-test"
|
||||||
main = "dist/worker/index.js"
|
main = "dist/worker/index.js"
|
||||||
compatibility_date = "2025-12-23"
|
compatibility_date = "2025-12-25"
|
||||||
compatibility_flags = ["nodejs_compat"]
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
[assets]
|
[assets]
|
||||||
@@ -10,3 +10,8 @@ not_found_handling = "single-page-application"
|
|||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
GLOBALPING_API = "https://api.globalping.io/v1"
|
GLOBALPING_API = "https://api.globalping.io/v1"
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "RESULTS_KV"
|
||||||
|
id = "8c7f404f066a4610bc8bab96e076295c"
|
||||||
|
preview_id = "8c7f404f066a4610bc8bab96e076295c"
|
||||||
|
|||||||
Reference in New Issue
Block a user