fix(all): 修复了一些bug
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -176,3 +176,4 @@ dist
|
|||||||
.claude
|
.claude
|
||||||
.idea
|
.idea
|
||||||
.wrangler
|
.wrangler
|
||||||
|
.kiro
|
||||||
@@ -86,8 +86,10 @@ export interface TestResult {
|
|||||||
|
|
||||||
export async function testAllNodes(
|
export async function testAllNodes(
|
||||||
target: string,
|
target: string,
|
||||||
onProgress: (result: LatencyResult) => void
|
onProgress: (result: LatencyResult) => void,
|
||||||
|
onPhase?: (phase: 'init' | 'testing' | 'traceroute') => void
|
||||||
): Promise<TestResult> {
|
): Promise<TestResult> {
|
||||||
|
onPhase?.('init')
|
||||||
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' })
|
||||||
}
|
}
|
||||||
@@ -107,6 +109,7 @@ export async function testAllNodes(
|
|||||||
|
|
||||||
const { measurementId, tracerouteId }: BatchMeasurementResponse = await res.json()
|
const { measurementId, tracerouteId }: BatchMeasurementResponse = await res.json()
|
||||||
|
|
||||||
|
onPhase?.('testing')
|
||||||
for (const node of TEST_NODES) {
|
for (const node of TEST_NODES) {
|
||||||
onProgress({ nodeId: node.id, latency: null, status: 'testing' })
|
onProgress({ nodeId: node.id, latency: null, status: 'testing' })
|
||||||
}
|
}
|
||||||
@@ -165,6 +168,7 @@ export async function testAllNodes(
|
|||||||
// Fetch traceroute results if available
|
// Fetch traceroute results if available
|
||||||
let traceroute: TracerouteStats | null = null
|
let traceroute: TracerouteStats | null = null
|
||||||
if (tracerouteId) {
|
if (tracerouteId) {
|
||||||
|
onPhase?.('traceroute')
|
||||||
try {
|
try {
|
||||||
const trRes = await fetch(`${API_BASE}/latency/traceroute/${tracerouteId}`)
|
const trRes = await fetch(`${API_BASE}/latency/traceroute/${tracerouteId}`)
|
||||||
if (trRes.ok) {
|
if (trRes.ok) {
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
.compare-page {
|
.compare-page {
|
||||||
max-width: 1000px;
|
max-width: 1440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero Card - unified container for header, inputs, and progress */
|
||||||
|
.compare-page .hero-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 3rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 2rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compare-header {
|
.compare-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compare-title {
|
.compare-title {
|
||||||
font-size: 1.75rem;
|
font-size: 2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.5rem;
|
||||||
background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%);
|
background: linear-gradient(135deg, var(--text-color) 0%, var(--primary-color) 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
@@ -24,24 +44,50 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compare-inputs {
|
.compare-inputs-wrapper {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
align-items: flex-end;
|
||||||
gap: 1.5rem;
|
gap: 2rem;
|
||||||
margin-bottom: 1.5rem;
|
width: 100%;
|
||||||
background: var(--card-bg);
|
max-width: 900px;
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
backdrop-filter: var(--glass-blur);
|
|
||||||
-webkit-backdrop-filter: var(--glass-blur);
|
|
||||||
filter: drop-shadow(var(--shadow-glow));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group {
|
.input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-vs-divider {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
text-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-vs-divider::before,
|
||||||
|
.input-vs-divider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--primary-color));
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-vs-divider::before {
|
||||||
|
right: 100%;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-vs-divider::after {
|
||||||
|
left: 100%;
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
background: linear-gradient(90deg, var(--primary-color), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-label {
|
.input-label {
|
||||||
@@ -90,19 +136,14 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compare-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compare-button {
|
.compare-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 0.75rem;
|
||||||
padding: 14px 48px;
|
padding: 1rem 3rem;
|
||||||
border-radius: 12px;
|
margin-top: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -161,7 +202,7 @@
|
|||||||
/* Results Table */
|
/* Results Table */
|
||||||
.results-container {
|
.results-container {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border-radius: 1rem;
|
border-radius: 1.5rem;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
backdrop-filter: var(--glass-blur);
|
backdrop-filter: var(--glass-blur);
|
||||||
@@ -176,7 +217,7 @@
|
|||||||
|
|
||||||
.results-table th,
|
.results-table th,
|
||||||
.results-table td {
|
.results-table td {
|
||||||
padding: 0.875rem 1.25rem;
|
padding: 1rem 1.5rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
@@ -379,12 +420,37 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Share link card container */
|
/* Comparison Stats Grid - two column layout for A vs B */
|
||||||
.compare-share-container {
|
.comparison-stats-grid {
|
||||||
margin-top: -0.5rem;
|
display: grid;
|
||||||
margin-bottom: 2rem;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
animation: slideUp 0.3s ease-out;
|
}
|
||||||
|
|
||||||
|
.comparison-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column-header {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Share link card - match hero-card width */
|
||||||
|
.compare-page .share-link-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
@@ -399,11 +465,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.compare-inputs {
|
.hero-card {
|
||||||
grid-template-columns: 1fr;
|
padding: 1.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-inputs-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-vs-divider {
|
||||||
|
align-self: center;
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-vs-divider::before,
|
||||||
|
.input-vs-divider::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.compare-inputs.readonly {
|
.compare-inputs.readonly {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -415,6 +498,10 @@
|
|||||||
margin: -0.5rem 0;
|
margin: -0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comparison-stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.compare-ip-info-cards,
|
.compare-ip-info-cards,
|
||||||
.compare-score-cards {
|
.compare-score-cards {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -8,14 +8,31 @@ import ExpirationBanner from './ExpirationBanner'
|
|||||||
import ShareLinkCard from './ShareLinkCard'
|
import ShareLinkCard from './ShareLinkCard'
|
||||||
import IpInfoCard from './IpInfoCard'
|
import IpInfoCard from './IpInfoCard'
|
||||||
import ScoreCard from './ScoreCard'
|
import ScoreCard from './ScoreCard'
|
||||||
|
import TestProgressBar, { TestPhase } from './TestProgressBar'
|
||||||
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)$/
|
||||||
const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/
|
const DOMAIN_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||||
|
|
||||||
|
function normalizeTarget(value: string): string {
|
||||||
|
let target = value.trim().toLowerCase()
|
||||||
|
// Remove protocol prefix if present
|
||||||
|
target = target.replace(/^https?:\/\//, '')
|
||||||
|
// Strip userinfo if a full URL with credentials was pasted
|
||||||
|
const atIndex = target.lastIndexOf('@')
|
||||||
|
if (atIndex !== -1) {
|
||||||
|
target = target.slice(atIndex + 1)
|
||||||
|
}
|
||||||
|
// Remove path, query, and fragment
|
||||||
|
target = target.split(/[/?#]/)[0]
|
||||||
|
// Remove port if present
|
||||||
|
target = target.split(':')[0]
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
function isValidTarget(value: string): boolean {
|
function isValidTarget(value: string): boolean {
|
||||||
const trimmed = value.trim().toLowerCase()
|
const normalized = normalizeTarget(value)
|
||||||
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
|
return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ComparePage() {
|
export default function ComparePage() {
|
||||||
@@ -39,6 +56,7 @@ export default function ComparePage() {
|
|||||||
const [ipInfoB, setIpInfoB] = useState<IpInfo | null>(null)
|
const [ipInfoB, setIpInfoB] = useState<IpInfo | null>(null)
|
||||||
const [tracerouteA, setTracerouteA] = useState<TracerouteStats | null>(null)
|
const [tracerouteA, setTracerouteA] = useState<TracerouteStats | null>(null)
|
||||||
const [tracerouteB, setTracerouteB] = useState<TracerouteStats | null>(null)
|
const [tracerouteB, setTracerouteB] = useState<TracerouteStats | null>(null)
|
||||||
|
const [phase, setPhase] = useState<TestPhase>('idle')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resultId) {
|
if (!resultId) {
|
||||||
@@ -126,10 +144,10 @@ export default function ComparePage() {
|
|||||||
const handleCompare = async () => {
|
const handleCompare = async () => {
|
||||||
if (isReadOnly) return
|
if (isReadOnly) return
|
||||||
|
|
||||||
const trimmedA = targetA.trim()
|
const normalizedA = normalizeTarget(targetA)
|
||||||
const trimmedB = targetB.trim()
|
const normalizedB = normalizeTarget(targetB)
|
||||||
const aValid = isValidTarget(trimmedA)
|
const aValid = isValidTarget(targetA)
|
||||||
const bValid = isValidTarget(trimmedB)
|
const bValid = isValidTarget(targetB)
|
||||||
|
|
||||||
setErrors({
|
setErrors({
|
||||||
a: aValid ? '' : t('无效的目标', 'Invalid target'),
|
a: aValid ? '' : t('无效的目标', 'Invalid target'),
|
||||||
@@ -138,7 +156,12 @@ export default function ComparePage() {
|
|||||||
|
|
||||||
if (!aValid || !bValid) return
|
if (!aValid || !bValid) return
|
||||||
|
|
||||||
|
// Update display to show normalized values
|
||||||
|
setTargetA(normalizedA)
|
||||||
|
setTargetB(normalizedB)
|
||||||
|
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
|
setPhase('init')
|
||||||
setResultsA(new Map())
|
setResultsA(new Map())
|
||||||
setResultsB(new Map())
|
setResultsB(new Map())
|
||||||
setShareUrl(null)
|
setShareUrl(null)
|
||||||
@@ -153,8 +176,10 @@ export default function ComparePage() {
|
|||||||
const rightResults = new Map<string, { nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>()
|
const rightResults = new Map<string, { nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [resA, resB] = await Promise.all([
|
// Test targets sequentially to avoid GlobalPing API rate limits
|
||||||
testAllNodes(trimmedA, (res) => {
|
const resA = await testAllNodes(
|
||||||
|
normalizedA,
|
||||||
|
(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') {
|
if (res.status === 'success' || res.status === 'failed') {
|
||||||
leftResults.set(res.nodeId, {
|
leftResults.set(res.nodeId, {
|
||||||
@@ -164,8 +189,16 @@ export default function ComparePage() {
|
|||||||
stats: res.stats
|
stats: res.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
testAllNodes(trimmedB, (res) => {
|
(newPhase) => {
|
||||||
|
if (newPhase === 'testing') setPhase('testing')
|
||||||
|
else if (newPhase === 'traceroute') setPhase('traceroute')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const resB = await testAllNodes(
|
||||||
|
normalizedB,
|
||||||
|
(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') {
|
if (res.status === 'success' || res.status === 'failed') {
|
||||||
rightResults.set(res.nodeId, {
|
rightResults.set(res.nodeId, {
|
||||||
@@ -175,8 +208,8 @@ export default function ComparePage() {
|
|||||||
stats: res.stats
|
stats: res.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
])
|
)
|
||||||
|
|
||||||
if (resA.resolvedAddress) {
|
if (resA.resolvedAddress) {
|
||||||
setResolvedIpA(resA.resolvedAddress)
|
setResolvedIpA(resA.resolvedAddress)
|
||||||
@@ -206,22 +239,29 @@ export default function ComparePage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setPhase('saving')
|
||||||
const { shareUrl: url } = await saveResult({
|
const { shareUrl: url } = await saveResult({
|
||||||
type: 'compare',
|
type: 'compare',
|
||||||
input: { leftTarget: trimmedA, rightTarget: trimmedB },
|
input: { leftTarget: normalizedA, rightTarget: normalizedB },
|
||||||
results: { left, right },
|
results: { left, right },
|
||||||
ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null },
|
ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null },
|
||||||
traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null }
|
traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null }
|
||||||
})
|
})
|
||||||
setShareUrl(url)
|
setShareUrl(url)
|
||||||
setShowShareModal(true)
|
setShowShareModal(true)
|
||||||
|
setPhase('complete')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save result:', e)
|
console.error('Failed to save result:', e)
|
||||||
|
setPhase('complete')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Comparison test failed:', e)
|
console.error('Comparison test failed:', e)
|
||||||
|
setPhase('complete')
|
||||||
} finally {
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
|
setPhase('idle')
|
||||||
|
}, 1500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +322,33 @@ export default function ComparePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="compare-page">
|
<div className="compare-page">
|
||||||
|
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
||||||
|
|
||||||
|
{isReadOnly ? (
|
||||||
|
<>
|
||||||
|
<div className="compare-header">
|
||||||
|
<h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
|
||||||
|
<p className="compare-subtitle">
|
||||||
|
{t('对比两个不同目标的全球延迟表现', 'Compare global latency performance between two targets')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="readonly-badge" style={{ textAlign: 'center', marginBottom: '1.5rem' }}>{t('只读模式', 'View Only')}</div>
|
||||||
|
<div className="comparison-stats-grid">
|
||||||
|
<div className="comparison-column">
|
||||||
|
<div className="comparison-column-header">{targetA}</div>
|
||||||
|
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
|
||||||
|
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
|
||||||
|
</div>
|
||||||
|
<div className="comparison-column">
|
||||||
|
<div className="comparison-column-header">{targetB}</div>
|
||||||
|
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
|
||||||
|
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hero-card">
|
||||||
<div className="compare-header">
|
<div className="compare-header">
|
||||||
<h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
|
<h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
|
||||||
<p className="compare-subtitle">
|
<p className="compare-subtitle">
|
||||||
@@ -289,28 +356,7 @@ export default function ComparePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
<div className="compare-inputs-wrapper">
|
||||||
|
|
||||||
{isReadOnly ? (
|
|
||||||
<div className="compare-inputs readonly">
|
|
||||||
<div className="readonly-input-group">
|
|
||||||
<span className="readonly-label">{t('目标 A', 'Target A')}</span>
|
|
||||||
<span className="readonly-value">{targetA}</span>
|
|
||||||
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
|
|
||||||
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
|
|
||||||
</div>
|
|
||||||
<div className="readonly-vs">VS</div>
|
|
||||||
<div className="readonly-input-group">
|
|
||||||
<span className="readonly-label">{t('目标 B', 'Target B')}</span>
|
|
||||||
<span className="readonly-value">{targetB}</span>
|
|
||||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
|
||||||
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
|
|
||||||
</div>
|
|
||||||
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="compare-inputs">
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">{t('目标 A', 'Target A')}</label>
|
<label className="input-label">{t('目标 A', 'Target A')}</label>
|
||||||
<input
|
<input
|
||||||
@@ -322,6 +368,9 @@ export default function ComparePage() {
|
|||||||
/>
|
/>
|
||||||
{errors.a && <span className="input-error">{errors.a}</span>}
|
{errors.a && <span className="input-error">{errors.a}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="input-vs-divider">VS</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">{t('目标 B', 'Target B')}</label>
|
<label className="input-label">{t('目标 B', 'Target B')}</label>
|
||||||
<input
|
<input
|
||||||
@@ -335,7 +384,6 @@ export default function ComparePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="compare-actions">
|
|
||||||
<button
|
<button
|
||||||
className="compare-button"
|
className="compare-button"
|
||||||
onClick={handleCompare}
|
onClick={handleCompare}
|
||||||
@@ -350,25 +398,35 @@ export default function ComparePage() {
|
|||||||
t('开始对比', 'Start Comparison')
|
t('开始对比', 'Start Comparison')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<TestProgressBar
|
||||||
|
current={
|
||||||
|
Array.from(resultsA.values()).filter(r => r.status === 'success' || r.status === 'failed').length +
|
||||||
|
Array.from(resultsB.values()).filter(r => r.status === 'success' || r.status === 'failed').length
|
||||||
|
}
|
||||||
|
total={TEST_NODES.length * 2}
|
||||||
|
phase={phase}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(shareUrl || resultsA.size > 0 || resultsB.size > 0) && !testing && (
|
{(resultsA.size > 0 || resultsB.size > 0) && !testing && (
|
||||||
<div className="compare-share-container">
|
<div className="comparison-stats-grid">
|
||||||
{(ipInfoA || ipInfoB) && (
|
<div className="comparison-column">
|
||||||
<div className="compare-ip-info-cards">
|
<div className="comparison-column-header">{targetA || 'Target A'}</div>
|
||||||
{ipInfoA && <IpInfoCard info={ipInfoA} compact className="compare-ip-card" />}
|
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
|
||||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact className="compare-ip-card" />}
|
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="comparison-column">
|
||||||
{(resultsA.size > 0 || resultsB.size > 0) && (
|
<div className="comparison-column-header">{targetB || 'Target B'}</div>
|
||||||
<div className="compare-score-cards">
|
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
|
||||||
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact className="compare-score-card" />}
|
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
||||||
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact className="compare-score-card" />}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{shareUrl && !testing && <ShareLinkCard shareUrl={shareUrl} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import ExpirationBanner from './ExpirationBanner'
|
|||||||
import ShareLinkCard from './ShareLinkCard'
|
import ShareLinkCard from './ShareLinkCard'
|
||||||
import IpInfoCard from './IpInfoCard'
|
import IpInfoCard from './IpInfoCard'
|
||||||
import ScoreCard from './ScoreCard'
|
import ScoreCard from './ScoreCard'
|
||||||
|
import TestProgressBar, { TestPhase } from './TestProgressBar'
|
||||||
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
|
import { testAllNodes, saveResult, fetchSavedResult } from '../api/latency'
|
||||||
import { LatencyResult, IpInfo, TracerouteStats } from '@shared/types'
|
import { LatencyResult, IpInfo, TracerouteStats, TEST_NODES } from '@shared/types'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -27,6 +28,7 @@ export default function HomePage() {
|
|||||||
const [resolvedIp, setResolvedIp] = useState<string | null>(null)
|
const [resolvedIp, setResolvedIp] = useState<string | null>(null)
|
||||||
const [ipInfo, setIpInfo] = useState<IpInfo | null>(null)
|
const [ipInfo, setIpInfo] = useState<IpInfo | null>(null)
|
||||||
const [traceroute, setTraceroute] = useState<TracerouteStats | null>(null)
|
const [traceroute, setTraceroute] = useState<TracerouteStats | null>(null)
|
||||||
|
const [phase, setPhase] = useState<TestPhase>('idle')
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
// Load saved result if viewing shared link
|
// Load saved result if viewing shared link
|
||||||
@@ -77,6 +79,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const handleTest = useCallback(async (testTarget: string) => {
|
const handleTest = useCallback(async (testTarget: string) => {
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
|
setPhase('init')
|
||||||
setResults(new Map())
|
setResults(new Map())
|
||||||
setSelectedNodeId(null)
|
setSelectedNodeId(null)
|
||||||
setShareUrl(null)
|
setShareUrl(null)
|
||||||
@@ -87,7 +90,9 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }> = []
|
const finalResults: Array<{ nodeId: string; latency: number | null; success: boolean; stats?: { min: number; max: number; loss: number } }> = []
|
||||||
|
|
||||||
const { resolvedAddress, ipInfo: fetchedIpInfo, traceroute: fetchedTraceroute } = await testAllNodes(testTarget, (result) => {
|
const { resolvedAddress, ipInfo: fetchedIpInfo, traceroute: fetchedTraceroute } = await testAllNodes(
|
||||||
|
testTarget,
|
||||||
|
(result) => {
|
||||||
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
setResults((prev) => new Map(prev).set(result.nodeId, result))
|
||||||
if (result.status === 'success' || result.status === 'failed') {
|
if (result.status === 'success' || result.status === 'failed') {
|
||||||
finalResults.push({
|
finalResults.push({
|
||||||
@@ -97,7 +102,9 @@ export default function HomePage() {
|
|||||||
stats: result.stats
|
stats: result.stats
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
(newPhase) => setPhase(newPhase)
|
||||||
|
)
|
||||||
|
|
||||||
if (resolvedAddress) {
|
if (resolvedAddress) {
|
||||||
setResolvedIp(resolvedAddress)
|
setResolvedIp(resolvedAddress)
|
||||||
@@ -113,6 +120,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
// Save result and get share URL
|
// Save result and get share URL
|
||||||
try {
|
try {
|
||||||
|
setPhase('saving')
|
||||||
const { shareUrl: url } = await saveResult({
|
const { shareUrl: url } = await saveResult({
|
||||||
type: 'single',
|
type: 'single',
|
||||||
input: { target: testTarget },
|
input: { target: testTarget },
|
||||||
@@ -122,11 +130,16 @@ export default function HomePage() {
|
|||||||
})
|
})
|
||||||
setShareUrl(url)
|
setShareUrl(url)
|
||||||
setShowShareModal(true)
|
setShowShareModal(true)
|
||||||
|
setPhase('complete')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save result:', e)
|
console.error('Failed to save result:', e)
|
||||||
|
setPhase('complete')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
|
setPhase('idle')
|
||||||
|
}, 1500)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||||
@@ -152,54 +165,90 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const currentUrl = isReadOnly ? window.location.href : undefined
|
const currentUrl = isReadOnly ? window.location.href : undefined
|
||||||
|
|
||||||
|
const hasResults = results.size > 0 || ipInfo || shareUrl
|
||||||
|
const hasScoreCard = results.size > 0
|
||||||
|
const hasInfoCard = !!ipInfo || (!!resolvedIp && target !== resolvedIp)
|
||||||
|
const hasTopRow = hasScoreCard || hasInfoCard
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
||||||
|
|
||||||
|
<div className="hero-card">
|
||||||
<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>
|
||||||
|
|
||||||
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
|
||||||
|
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<div className="readonly-input-container">
|
<div className="readonly-input-container">
|
||||||
<div className="readonly-input">
|
<div className="readonly-input">
|
||||||
<span className="readonly-label">{t('测试目标', 'Test Target')}</span>
|
<span className="readonly-label">{t('测试目标', 'Test Target')}</span>
|
||||||
<span className="readonly-value">{target}</span>
|
<span className="readonly-value">{target}</span>
|
||||||
</div>
|
</div>
|
||||||
{ipInfo && <IpInfoCard info={ipInfo} compact />}
|
|
||||||
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact />}
|
|
||||||
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IpInput onTest={handleTest} testing={testing} />
|
<IpInput onTest={handleTest} testing={testing} />
|
||||||
{(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl || results.size > 0) && (
|
<TestProgressBar
|
||||||
<div className="test-result-info">
|
current={Array.from(results.values()).filter(r => r.status === 'success' || r.status === 'failed').length}
|
||||||
{ipInfo ? (
|
total={TEST_NODES.length}
|
||||||
<IpInfoCard info={ipInfo} compact />
|
phase={phase}
|
||||||
) : resolvedIp && target !== resolvedIp ? (
|
/>
|
||||||
<div className="resolved-ip-info">
|
|
||||||
<span className="resolved-ip-label">{t('解析IP', 'Resolved IP')}</span>
|
|
||||||
<span className="resolved-ip-value">{resolvedIp}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact testing={testing} />}
|
|
||||||
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="layout-container">
|
||||||
|
{/* Map Section - The Visual Hero */}
|
||||||
|
<div className="map-section">
|
||||||
<LatencyMap
|
<LatencyMap
|
||||||
results={results}
|
results={results}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
onNodeSelect={handleNodeSelect}
|
onNodeSelect={handleNodeSelect}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid - Cards below map */}
|
||||||
|
{hasResults && (
|
||||||
|
<div className="stats-grid">
|
||||||
|
{hasTopRow && (
|
||||||
|
<div className="stats-top-row">
|
||||||
|
{hasScoreCard && (
|
||||||
|
<div className="stats-score-col">
|
||||||
|
<ScoreCard
|
||||||
|
results={Array.from(results.values())}
|
||||||
|
ipInfo={ipInfo}
|
||||||
|
traceroute={traceroute}
|
||||||
|
testing={testing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasInfoCard && (
|
||||||
|
<div className="stats-info-col">
|
||||||
|
{ipInfo ? (
|
||||||
|
<IpInfoCard info={ipInfo} />
|
||||||
|
) : (
|
||||||
|
<div className="resolved-ip-info">
|
||||||
|
<span className="resolved-ip-label">{t('解析IP', 'Resolved IP')}</span>
|
||||||
|
<span className="resolved-ip-value">{resolvedIp}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Table */}
|
||||||
<ResultsPanel
|
<ResultsPanel
|
||||||
results={results}
|
results={results}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
onNodeSelect={handleNodeSelect}
|
onNodeSelect={handleNodeSelect}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ShareModal
|
<ShareModal
|
||||||
isOpen={showShareModal}
|
isOpen={showShareModal}
|
||||||
|
|||||||
@@ -9,11 +9,27 @@ interface IpInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||||
|
|
||||||
|
function normalizeTarget(value: string): string {
|
||||||
|
let target = value.trim().toLowerCase()
|
||||||
|
// Remove protocol prefix if present
|
||||||
|
target = target.replace(/^https?:\/\//, '')
|
||||||
|
// Strip userinfo if a full URL with credentials was pasted
|
||||||
|
const atIndex = target.lastIndexOf('@')
|
||||||
|
if (atIndex !== -1) {
|
||||||
|
target = target.slice(atIndex + 1)
|
||||||
|
}
|
||||||
|
// Remove path, query, and fragment
|
||||||
|
target = target.split(/[/?#]/)[0]
|
||||||
|
// Remove port if present
|
||||||
|
target = target.split(':')[0]
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
function isValidTarget(value: string): boolean {
|
function isValidTarget(value: string): boolean {
|
||||||
const trimmed = value.trim().toLowerCase()
|
const normalized = normalizeTarget(value)
|
||||||
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
|
return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IpInput({ onTest, testing }: IpInputProps) {
|
export default function IpInput({ onTest, testing }: IpInputProps) {
|
||||||
@@ -31,13 +47,14 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = target.trim()
|
if (!isValidTarget(target)) {
|
||||||
if (!isValidTarget(trimmed)) {
|
|
||||||
setError(t('无效的IP地址或域名', 'Invalid IP address or domain'))
|
setError(t('无效的IP地址或域名', 'Invalid IP address or domain'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const normalized = normalizeTarget(target)
|
||||||
|
setTarget(normalized) // Update display to show normalized value
|
||||||
setError('')
|
setError('')
|
||||||
onTest(trimmed)
|
onTest(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
.map-container {
|
.map-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
height: 100%;
|
||||||
height: 500px;
|
min-height: 500px;
|
||||||
margin: 0 auto 3rem;
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
@@ -152,6 +151,7 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.map-container {
|
.map-container {
|
||||||
height: 400px;
|
height: 400px;
|
||||||
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.globe-popup {
|
.globe-popup {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useEffect, useRef } from 'react'
|
import { useMemo, useEffect, useRef, useState } from 'react'
|
||||||
import Globe, { GlobeMethods } from 'react-globe.gl'
|
import Globe, { GlobeMethods } from 'react-globe.gl'
|
||||||
import { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types'
|
import { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
@@ -14,6 +14,27 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
|
|||||||
const globeEl = useRef<GlobeMethods | undefined>(undefined)
|
const globeEl = useRef<GlobeMethods | undefined>(undefined)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateDimensions = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setDimensions({
|
||||||
|
width: containerRef.current.clientWidth,
|
||||||
|
height: containerRef.current.clientHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDimensions()
|
||||||
|
const ro = new ResizeObserver(updateDimensions)
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
ro.observe(containerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (globeEl.current) {
|
if (globeEl.current) {
|
||||||
@@ -73,6 +94,8 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
|
|||||||
<div className="map-container" ref={containerRef}>
|
<div className="map-container" ref={containerRef}>
|
||||||
<Globe
|
<Globe
|
||||||
ref={globeEl}
|
ref={globeEl}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
|
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
|
||||||
backgroundImageUrl="//unpkg.com/three-globe/example/img/night-sky.png"
|
backgroundImageUrl="//unpkg.com/three-globe/example/img/night-sky.png"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
.results-panel {
|
.results-panel {
|
||||||
max-width: 1000px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
animation: fadeIn 0.5s ease-out;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
src/client/components/TestProgressBar.css
Normal file
111
src/client/components/TestProgressBar.css
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
.test-progress-container {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1.5rem auto 0;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: all var(--transition-smooth);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress-container.active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress-percentage {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress-track {
|
||||||
|
position: relative;
|
||||||
|
height: 12px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress-bar-bg {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--background-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s ease-out;
|
||||||
|
box-shadow: 0 0 10px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-markers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
z-index: 2;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-marker.active {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-label-item {
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-label-item.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.test-progress-label,
|
||||||
|
.test-progress-percentage {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-labels {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/client/components/TestProgressBar.tsx
Normal file
77
src/client/components/TestProgressBar.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import './TestProgressBar.css'
|
||||||
|
|
||||||
|
export type TestPhase = 'idle' | 'init' | 'testing' | 'traceroute' | 'saving' | 'complete'
|
||||||
|
|
||||||
|
interface TestProgressBarProps {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
phase: TestPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TestProgressBar({ current, total, phase }: TestProgressBarProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
let percentage = 0
|
||||||
|
let label = ''
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case 'idle':
|
||||||
|
percentage = 0
|
||||||
|
break
|
||||||
|
case 'init':
|
||||||
|
percentage = 5
|
||||||
|
label = t('正在初始化...', 'Initializing...')
|
||||||
|
break
|
||||||
|
case 'testing':
|
||||||
|
// Map 0-100% of testing to 5-85% of total progress
|
||||||
|
const testingProgress = total > 0 ? current / total : 0
|
||||||
|
percentage = 5 + testingProgress * 80
|
||||||
|
label = t(`正在测试节点 (${current}/${total})`, `Testing nodes (${current}/${total})`)
|
||||||
|
break
|
||||||
|
case 'traceroute':
|
||||||
|
percentage = 90
|
||||||
|
label = t('分析路由路径...', 'Analyzing routes...')
|
||||||
|
break
|
||||||
|
case 'saving':
|
||||||
|
percentage = 95
|
||||||
|
label = t('保存结果...', 'Saving results...')
|
||||||
|
break
|
||||||
|
case 'complete':
|
||||||
|
percentage = 100
|
||||||
|
label = t('测试完成', 'Test Complete')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
percentage = Math.min(Math.round(percentage), 100)
|
||||||
|
|
||||||
|
if (phase === 'idle') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="test-progress-container active">
|
||||||
|
<div className="test-progress-info">
|
||||||
|
<span className="test-progress-label">{label}</span>
|
||||||
|
<span className="test-progress-percentage">{percentage}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="test-progress-track">
|
||||||
|
<div className="test-progress-bar-bg">
|
||||||
|
<div className="test-progress-fill" style={{ width: `${percentage}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="phase-markers">
|
||||||
|
<div className={`phase-marker ${percentage >= 5 ? 'active' : ''}`} style={{ left: '5%' }} />
|
||||||
|
<div className={`phase-marker ${percentage >= 85 ? 'active' : ''}`} style={{ left: '85%' }} />
|
||||||
|
<div className={`phase-marker ${percentage >= 95 ? 'active' : ''}`} style={{ left: '95%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="phase-labels">
|
||||||
|
<span className={`phase-label-item ${phase === 'init' ? 'active' : ''}`}>{t('初始化', 'Init')}</span>
|
||||||
|
<span className={`phase-label-item ${phase === 'testing' ? 'active' : ''}`}>{t('测试', 'Test')}</span>
|
||||||
|
<span className={`phase-label-item ${phase === 'traceroute' ? 'active' : ''}`}>{t('路由', 'Trace')}</span>
|
||||||
|
<span className={`phase-label-item ${phase === 'saving' || phase === 'complete' ? 'active' : ''}`}>{t('完成', 'Done')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -117,8 +117,8 @@ body {
|
|||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 3rem 2rem;
|
padding: 2rem;
|
||||||
max-width: 1200px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -206,6 +206,41 @@ body {
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hero Card Section */
|
||||||
|
.hero-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card .app-description {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card .ip-input-form {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-card {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading and Error States */
|
/* Loading and Error States */
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -330,3 +365,149 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- New Layout System --- */
|
||||||
|
|
||||||
|
/* Main layout container - vertical stack for breathing room */
|
||||||
|
.layout-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map Section - The Visual Hero/Focus */
|
||||||
|
.map-section {
|
||||||
|
width: 100%;
|
||||||
|
height: 55vh;
|
||||||
|
min-height: 450px;
|
||||||
|
max-height: 650px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 8px 32px -4px rgba(0, 0, 0, 0.08);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Grid - Cards arranged below map */
|
||||||
|
.stats-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top row: ScoreCard + IpInfoCard side by side */
|
||||||
|
.stats-top-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid > * {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-score-col > *,
|
||||||
|
.stats-info-col > * {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: side by side layout */
|
||||||
|
@media (min-width: 850px) {
|
||||||
|
.stats-top-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-score-col {
|
||||||
|
flex: 1.6;
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-info-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ShareLinkCard full width */
|
||||||
|
.stats-grid .share-link-card {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refined Resolved IP Card */
|
||||||
|
.stats-grid .resolved-ip-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.map-section {
|
||||||
|
height: 45vh;
|
||||||
|
min-height: 350px;
|
||||||
|
max-height: 450px;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-container {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Comparison Grid Layout --- */
|
||||||
|
.comparison-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.comparison-stats-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-column-header {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,10 +148,27 @@ function generateId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||||
|
|
||||||
|
function normalizeTarget(value: string): string {
|
||||||
|
let target = value.trim().toLowerCase()
|
||||||
|
// Remove protocol prefix if present
|
||||||
|
target = target.replace(/^https?:\/\//, '')
|
||||||
|
// Strip userinfo if a full URL with credentials was pasted
|
||||||
|
const atIndex = target.lastIndexOf('@')
|
||||||
|
if (atIndex !== -1) {
|
||||||
|
target = target.slice(atIndex + 1)
|
||||||
|
}
|
||||||
|
// Remove path, query, and fragment
|
||||||
|
target = target.split(/[/?#]/)[0]
|
||||||
|
// Remove port if present
|
||||||
|
target = target.split(':')[0]
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
function isValidTarget(target: string): boolean {
|
function isValidTarget(target: string): boolean {
|
||||||
return IP_REGEX.test(target) || DOMAIN_REGEX.test(target)
|
const normalized = normalizeTarget(target)
|
||||||
|
return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIPAddress(target: string): boolean {
|
function isIPAddress(target: string): boolean {
|
||||||
@@ -536,16 +553,17 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
|
|||||||
return jsonResponse({ error: 'target is required' }, 400)
|
return jsonResponse({ error: 'target is required' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedTarget = target.trim().toLowerCase()
|
if (!isValidTarget(target)) {
|
||||||
|
|
||||||
if (!isValidTarget(trimmedTarget)) {
|
|
||||||
return jsonResponse({ error: 'Invalid target. Please enter a valid IP address or domain name.' }, 400)
|
return jsonResponse({ error: 'Invalid target. Please enter a valid IP address or domain name.' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use normalized target for API calls
|
||||||
|
const normalizedTarget = normalizeTarget(target)
|
||||||
|
|
||||||
// Resolve domain to IP for traceroute (GlobalPing requires IP)
|
// Resolve domain to IP for traceroute (GlobalPing requires IP)
|
||||||
let tracerouteTarget = trimmedTarget
|
let tracerouteTarget = normalizedTarget
|
||||||
if (!isIPAddress(trimmedTarget)) {
|
if (!isIPAddress(normalizedTarget)) {
|
||||||
const resolvedIP = await resolveDomainToIP(trimmedTarget)
|
const resolvedIP = await resolveDomainToIP(normalizedTarget)
|
||||||
if (resolvedIP) {
|
if (resolvedIP) {
|
||||||
tracerouteTarget = resolvedIP
|
tracerouteTarget = resolvedIP
|
||||||
}
|
}
|
||||||
@@ -553,7 +571,7 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
|
|||||||
|
|
||||||
// Create both ping and traceroute measurements in parallel
|
// Create both ping and traceroute measurements in parallel
|
||||||
const [measurementId, tracerouteId] = await Promise.all([
|
const [measurementId, tracerouteId] = await Promise.all([
|
||||||
createBatchMeasurement(trimmedTarget, env),
|
createBatchMeasurement(normalizedTarget, env),
|
||||||
createMtrMeasurement(tracerouteTarget, env)
|
createMtrMeasurement(tracerouteTarget, env)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name = "latency-test"
|
name = "latency-test"
|
||||||
main = "dist/worker/index.js"
|
main = "dist/worker/index.js"
|
||||||
compatibility_date = "2025-12-25"
|
compatibility_date = "2025-12-28"
|
||||||
compatibility_flags = ["nodejs_compat"]
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
[assets]
|
[assets]
|
||||||
|
|||||||
Reference in New Issue
Block a user