From 7fdaf4da3d6771d719c50f1b0766c4829beb0e188867ef1319fedbdc01c59a54 Mon Sep 17 00:00:00 2001 From: ahdoawhfo Date: Thu, 25 Dec 2025 11:29:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(record):=20=E5=A2=9E=E5=8A=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=BB=93=E6=9E=9C=E4=BF=9D=E5=AD=98=E5=92=8C=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en-US.md | 90 ++++++ README.md | 90 +++++- src/client/App.tsx | 2 + src/client/api/latency.ts | 77 ++++- src/client/components/ComparePage.css | 115 ++++++++ src/client/components/ComparePage.tsx | 318 +++++++++++++++++---- src/client/components/ExpirationBanner.css | 51 ++++ src/client/components/ExpirationBanner.tsx | 43 +++ src/client/components/HomePage.tsx | 157 +++++++++- src/client/components/IpInfoCard.css | 168 +++++++++++ src/client/components/IpInfoCard.tsx | 62 ++++ src/client/components/ShareLinkCard.css | 95 ++++++ src/client/components/ShareLinkCard.tsx | 48 ++++ src/client/components/ShareModal.css | 165 +++++++++++ src/client/components/ShareModal.tsx | 66 +++++ src/client/components/Toast.css | 47 +++ src/client/components/Toast.tsx | 31 ++ src/client/styles/index.css | 177 ++++++++++++ src/server/index.ts | 67 ++++- src/shared/types.ts | 9 + src/worker/index.ts | 282 +++++++++++++++++- wrangler.toml | 7 +- 22 files changed, 2103 insertions(+), 64 deletions(-) create mode 100644 README.en-US.md create mode 100644 src/client/components/ExpirationBanner.css create mode 100644 src/client/components/ExpirationBanner.tsx create mode 100644 src/client/components/IpInfoCard.css create mode 100644 src/client/components/IpInfoCard.tsx create mode 100644 src/client/components/ShareLinkCard.css create mode 100644 src/client/components/ShareLinkCard.tsx create mode 100644 src/client/components/ShareModal.css create mode 100644 src/client/components/ShareModal.tsx create mode 100644 src/client/components/Toast.css create mode 100644 src/client/components/Toast.tsx diff --git a/README.en-US.md b/README.en-US.md new file mode 100644 index 0000000..4bdd46d --- /dev/null +++ b/README.en-US.md @@ -0,0 +1,90 @@ +# 🌐 LatencyTest + +[![React](https://img.shields.io/badge/React-18.3-61DAFB?style=flat-square&logo=react&logoColor=white)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Vite](https://img.shields.io/badge/Vite-6.0-646CFF?style=flat-square&logo=vite&logoColor=white)](https://vitejs.dev/) +[![Cloudflare Workers](https://img.shields.io/badge/Cloudflare_Workers-F38020?style=flat-square&logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/) +[![Node.js](https://img.shields.io/badge/Node.js-Express-339933?style=flat-square&logo=node.js&logoColor=white)](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 \ No newline at end of file diff --git a/README.md b/README.md index 680ad98..42f3e81 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,90 @@ -# LatencyTest +# 🌐 LatencyTest +[![React](https://img.shields.io/badge/React-18.3-61DAFB?style=flat-square&logo=react&logoColor=white)](https://react.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Vite](https://img.shields.io/badge/Vite-6.0-646CFF?style=flat-square&logo=vite&logoColor=white)](https://vitejs.dev/) +[![Cloudflare Workers](https://img.shields.io/badge/Cloudflare_Workers-F38020?style=flat-square&logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/) +[![Node.js](https://img.shields.io/badge/Node.js-Express-339933?style=flat-square&logo=node.js&logoColor=white)](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 +``` + +## 📸 截图 + +> *截图待添加* + +## 📄 许可证 + +待定 diff --git a/src/client/App.tsx b/src/client/App.tsx index e357d29..b140051 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -28,7 +28,9 @@ function AppRoutes() { } /> + } /> } /> + } /> diff --git a/src/client/api/latency.ts b/src/client/api/latency.ts index 1c10e20..1c8a058 100644 --- a/src/client/api/latency.ts +++ b/src/client/api/latency.ts @@ -1,4 +1,4 @@ -import { LatencyResult, TEST_NODES } from '@shared/types' +import { LatencyResult, TEST_NODES, IpInfo } from '@shared/types' const API_BASE = '/api' @@ -17,6 +17,34 @@ export interface BatchResultResponse { latency: number | null 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 { @@ -26,10 +54,15 @@ export async function fetchUserIp(): Promise { return data.ip } +export interface TestResult { + resolvedAddress?: string + ipInfo?: IpInfo | null +} + export async function testAllNodes( target: string, onProgress: (result: LatencyResult) => void -): Promise { +): Promise { for (const node of TEST_NODES) { onProgress({ nodeId: node.id, latency: null, status: 'pending' }) } @@ -44,7 +77,7 @@ 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() @@ -56,6 +89,8 @@ export async function testAllNodes( const startTime = Date.now() const timeout = 60000 const completedNodes = new Set() + let resolvedAddress: string | undefined + let ipInfo: IpInfo | null | undefined while (Date.now() - startTime < timeout) { await new Promise(r => setTimeout(r, 800)) @@ -65,6 +100,16 @@ 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) @@ -89,4 +134,30 @@ export async function testAllNodes( break } } + + return { resolvedAddress, ipInfo } +} + +export async function saveResult(data: SaveResultRequest): Promise { + 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 { + const res = await fetch(`${API_BASE}/results/${id}`) + + if (!res.ok) { + throw new Error('Failed to fetch result') + } + + return res.json() } diff --git a/src/client/components/ComparePage.css b/src/client/components/ComparePage.css index c9e50c5..01a4c86 100644 --- a/src/client/components/ComparePage.css +++ b/src/client/components/ComparePage.css @@ -291,12 +291,127 @@ 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) { .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 { + grid-template-columns: 1fr; + } + .results-table th, .results-table td { padding: 0.625rem 0.75rem; diff --git a/src/client/components/ComparePage.tsx b/src/client/components/ComparePage.tsx index d289a86..ed23e20 100644 --- a/src/client/components/ComparePage.tsx +++ b/src/client/components/ComparePage.tsx @@ -1,7 +1,12 @@ -import { useState } from 'react' -import { testAllNodes } from '../api/latency' +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 } 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' 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() { + const { id: resultId } = useParams<{ id: string }>() + const isReadOnly = !!resultId const { t } = useLanguage() + const [targetA, setTargetA] = useState('') const [targetB, setTargetB] = useState('') const [resultsA, setResultsA] = useState>(new Map()) const [resultsB, setResultsB] = useState>(new Map()) const [testing, setTesting] = useState(false) const [errors, setErrors] = useState({ a: '', b: '' }) + const [shareUrl, setShareUrl] = useState(null) + const [loading, setLoading] = useState(isReadOnly) + const [loadError, setLoadError] = useState(null) + const [showShareModal, setShowShareModal] = useState(false) + const [resolvedIpA, setResolvedIpA] = useState(null) + const [resolvedIpB, setResolvedIpB] = useState(null) + const [ipInfoA, setIpInfoA] = useState(null) + const [ipInfoB, setIpInfoB] = useState(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() + const rightMap = new Map() + + 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 aValid = isValidTarget(targetA) - const bValid = isValidTarget(targetB) + 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'), @@ -35,16 +127,72 @@ export default function ComparePage() { setTesting(true) setResultsA(new Map()) setResultsB(new Map()) + setShareUrl(null) + setResolvedIpA(null) + setResolvedIpB(null) + setIpInfoA(null) + setIpInfoB(null) + + const leftResults = new Map() + const rightResults = new Map() try { - await Promise.all([ - testAllNodes(targetA.trim(), (res) => { + 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' + }) + } }), - testAllNodes(targetB.trim(), (res) => { + 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' + }) + } }) ]) + + 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) { console.error('Comparison test failed:', e) } finally { @@ -88,6 +236,25 @@ export default function ComparePage() { const summary = calculateSummary() const hasResults = resultsA.size > 0 || resultsB.size > 0 + if (loading) { + return ( +
+ +

{t('加载中...', 'Loading...')}

+
+ ) + } + + if (loadError) { + return ( +
+

{loadError}

+
+ ) + } + + const currentUrl = isReadOnly ? window.location.href : undefined + return (
@@ -97,47 +264,80 @@ export default function ComparePage() {

-
-
- - { setTargetA(e.target.value); setErrors(prev => ({ ...prev, a: '' })) }} - placeholder={t('输入IP或域名', 'Enter IP or domain')} - disabled={testing} - /> - {errors.a && {errors.a}} -
-
- - { setTargetB(e.target.value); setErrors(prev => ({ ...prev, b: '' })) }} - placeholder={t('输入IP或域名', 'Enter IP or domain')} - disabled={testing} - /> - {errors.b && {errors.b}} -
-
+ {isReadOnly && } -
- +
+ + {shareUrl && !testing && ( +
+ {(ipInfoA || ipInfoB) && ( +
+ {ipInfoA && } + {ipInfoB && } +
+ )} + +
)} - -
+ + )} {hasResults && ( <> @@ -146,8 +346,22 @@ export default function ComparePage() { {t('节点', 'Node')} - {targetA || 'A'} - {targetB || 'B'} + +
+ {targetA || 'A'} + {resolvedIpA && !IP_REGEX.test(targetA.trim()) && ( + {resolvedIpA} + )} +
+ + +
+ {targetB || 'B'} + {resolvedIpB && !IP_REGEX.test(targetB.trim()) && ( + {resolvedIpB} + )} +
+ {t('差值', 'Diff')} @@ -221,6 +435,12 @@ export default function ComparePage() { )} )} + + setShowShareModal(false)} + shareUrl={shareUrl || ''} + /> ) } diff --git a/src/client/components/ExpirationBanner.css b/src/client/components/ExpirationBanner.css new file mode 100644 index 0000000..9057c12 --- /dev/null +++ b/src/client/components/ExpirationBanner.css @@ -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; } +} diff --git a/src/client/components/ExpirationBanner.tsx b/src/client/components/ExpirationBanner.tsx new file mode 100644 index 0000000..2837b93 --- /dev/null +++ b/src/client/components/ExpirationBanner.tsx @@ -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 ( + <> +
+
+ ⚠️ +

+ {t('此结果链接随时可能失效。请及时保存重要数据。', 'This result link may expire at any time. Please save important data.')} +

+ {shareUrl && ( + + )} +
+
+ setShowToast(false)} + /> + + ) +} diff --git a/src/client/components/HomePage.tsx b/src/client/components/HomePage.tsx index 3efbc20..1870379 100644 --- a/src/client/components/HomePage.tsx +++ b/src/client/components/HomePage.tsx @@ -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 LatencyMap from './LatencyMap' import ResultsPanel from './ResultsPanel' -import { testAllNodes } from '../api/latency' -import { LatencyResult } from '@shared/types' +import ShareModal from './ShareModal' +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' export default function HomePage() { + const { id: resultId } = useParams<{ id: string }>() + const isReadOnly = !!resultId + const [results, setResults] = useState>(new Map()) const [testing, setTesting] = useState(false) const [selectedNodeId, setSelectedNodeId] = useState(null) + const [shareUrl, setShareUrl] = useState(null) + const [target, setTarget] = useState('') + const [loading, setLoading] = useState(isReadOnly) + const [error, setError] = useState(null) + const [showShareModal, setShowShareModal] = useState(false) + const [resolvedIp, setResolvedIp] = useState(null) + const [ipInfo, setIpInfo] = useState(null) 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() + 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) setResults(new Map()) 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)) + 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) }, []) @@ -28,12 +118,61 @@ export default function HomePage() { setSelectedNodeId(nodeId) }, []) + if (loading) { + return ( +
+ +

{t('加载中...', 'Loading...')}

+
+ ) + } + + if (error) { + return ( +
+

{error}

+
+ ) + } + + const currentUrl = isReadOnly ? window.location.href : undefined + return ( <>

{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}

- + + {isReadOnly && } + + {isReadOnly ? ( +
+
+ {t('测试目标', 'Test Target')} + {target} +
+ {ipInfo && } +
{t('只读模式', 'View Only')}
+
+ ) : ( + <> + + {(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl) && ( +
+ {ipInfo ? ( + + ) : resolvedIp && target !== resolvedIp ? ( +
+ {t('解析IP', 'Resolved IP')} + {resolvedIp} +
+ ) : null} + {shareUrl && } +
+ )} + + )} + + + setShowShareModal(false)} + shareUrl={shareUrl || ''} + /> ) } diff --git a/src/client/components/IpInfoCard.css b/src/client/components/IpInfoCard.css new file mode 100644 index 0000000..e6cf666 --- /dev/null +++ b/src/client/components/IpInfoCard.css @@ -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; + } +} diff --git a/src/client/components/IpInfoCard.tsx b/src/client/components/IpInfoCard.tsx new file mode 100644 index 0000000..111aa62 --- /dev/null +++ b/src/client/components/IpInfoCard.tsx @@ -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 ( +
+
+ IP + {info.ip} + {info.city && `${info.city}, `}{info.country || '-'} +
+
+ {info.org || info.isp || '-'} + {asnDisplay} +
+
+ ) + } + + return ( +
+
+
+ IP + {info.ip} +
+
+ + {info.city && `${info.city}, `}{info.country || '-'} + +
+
+ +
+
+ ISP + {info.isp || '-'} +
+
+ ASN + {asnDisplay} +
+
+ {t('组织', 'Organization')} + {info.org || '-'} +
+
+
+ ) +} diff --git a/src/client/components/ShareLinkCard.css b/src/client/components/ShareLinkCard.css new file mode 100644 index 0000000..62bab20 --- /dev/null +++ b/src/client/components/ShareLinkCard.css @@ -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; + } +} diff --git a/src/client/components/ShareLinkCard.tsx b/src/client/components/ShareLinkCard.tsx new file mode 100644 index 0000000..44c53d3 --- /dev/null +++ b/src/client/components/ShareLinkCard.tsx @@ -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 ( + <> +
+
+ {t('分享链接', 'Share Link')} + + ⚠️ {t('此链接随时可能失效', 'This link may expire at any time')} + +
+
+ + +
+
+ setShowToast(false)} + /> + + ) +} diff --git a/src/client/components/ShareModal.css b/src/client/components/ShareModal.css new file mode 100644 index 0000000..8dcdc7f --- /dev/null +++ b/src/client/components/ShareModal.css @@ -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; } +} diff --git a/src/client/components/ShareModal.tsx b/src/client/components/ShareModal.tsx new file mode 100644 index 0000000..df37845 --- /dev/null +++ b/src/client/components/ShareModal.tsx @@ -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 ( +
+
e.stopPropagation()}> + + +
+
🎉
+

{t('测试完成', 'Test Completed')}

+

+ {t('您的网络延迟测试已完成,分享此链接给他人查看结果。', 'Your network latency test is complete. Share this link to show results.')} +

+
+ +
+
+ + +
+ +
+ ⚠️ +

+ {t('结果链接随时可能失效,请根据需要保存测试结果。', 'Result link may expire at any time. Please save your test results as needed.')} +

+
+
+
+ + setShowToast(false)} + /> +
+ ) +} diff --git a/src/client/components/Toast.css b/src/client/components/Toast.css new file mode 100644 index 0000000..a695f41 --- /dev/null +++ b/src/client/components/Toast.css @@ -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); + } +} diff --git a/src/client/components/Toast.tsx b/src/client/components/Toast.tsx new file mode 100644 index 0000000..376d234 --- /dev/null +++ b/src/client/components/Toast.tsx @@ -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 ( +
+
+ + {message} +
+
+ ) +} diff --git a/src/client/styles/index.css b/src/client/styles/index.css index 88ae8d0..2dc5d47 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -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; + } } diff --git a/src/server/index.ts b/src/server/index.ts index 564a505..21a0801 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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 { + 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() + 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) diff --git a/src/shared/types.ts b/src/shared/types.ts index dc79a29..0cafc02 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,3 +1,12 @@ +export interface IpInfo { + ip: string + country: string + city: string + asn: number + org: string + isp: string +} + export interface TestNode { id: string name: string diff --git a/src/worker/index.ts b/src/worker/index.ts index b6d8210..52e5d2d 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,8 +1,9 @@ -import { TEST_NODES } from '../shared/types' +import { TEST_NODES, IpInfo } from '../shared/types' interface Env { GLOBALPING_API: string ASSETS?: { fetch: (request: Request) => Promise } + RESULTS_KV?: KVNamespace } interface MeasurementResponse { @@ -21,6 +22,7 @@ interface ProbeResult { result: { status: string rawOutput: string + resolvedAddress?: string stats?: { min: number max: number @@ -44,6 +46,37 @@ interface BatchLatencyResult { 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 = { 'Access-Control-Allow-Origin': '*', '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 DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/ @@ -141,6 +184,137 @@ function matchProbeToNode(probe: ProbeResult['probe']): string | 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 { + 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 { + const data = await kv.get('result:usage', 'json') as Partial | null + return { + totalBytes: data?.totalBytes ?? 0, + lastFullScanAt: data?.lastFullScanAt ?? 0 + } +} + +async function putUsage(kv: KVNamespace, usage: UsageState): Promise { + await kv.put('result:usage', JSON.stringify(usage)) +} + +async function recountUsage(kv: KVNamespace): Promise { + 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 { + 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 { + 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 { const ip = extractClientIp(request) if (!ip) { @@ -187,12 +361,18 @@ async function handleGetMeasurement(measurementId: string, env: Env): Promise() + 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') { @@ -211,9 +391,16 @@ async function handleGetMeasurement(measurementId: string, env: Env): Promise { + 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 { + 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 { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url) @@ -245,6 +513,16 @@ export default { 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 // 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 }) diff --git a/wrangler.toml b/wrangler.toml index 4231d02..a2a6813 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,6 @@ name = "latency-test" main = "dist/worker/index.js" -compatibility_date = "2025-12-23" +compatibility_date = "2025-12-25" compatibility_flags = ["nodejs_compat"] [assets] @@ -10,3 +10,8 @@ not_found_handling = "single-page-application" [vars] GLOBALPING_API = "https://api.globalping.io/v1" + +[[kv_namespaces]] +binding = "RESULTS_KV" +id = "8c7f404f066a4610bc8bab96e076295c" +preview_id = "8c7f404f066a4610bc8bab96e076295c"