fix(all): 修复了一些bug
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -176,3 +176,4 @@ dist
|
||||
.claude
|
||||
.idea
|
||||
.wrangler
|
||||
.kiro
|
||||
@@ -86,8 +86,10 @@ export interface TestResult {
|
||||
|
||||
export async function testAllNodes(
|
||||
target: string,
|
||||
onProgress: (result: LatencyResult) => void
|
||||
onProgress: (result: LatencyResult) => void,
|
||||
onPhase?: (phase: 'init' | 'testing' | 'traceroute') => void
|
||||
): Promise<TestResult> {
|
||||
onPhase?.('init')
|
||||
for (const node of TEST_NODES) {
|
||||
onProgress({ nodeId: node.id, latency: null, status: 'pending' })
|
||||
}
|
||||
@@ -107,6 +109,7 @@ export async function testAllNodes(
|
||||
|
||||
const { measurementId, tracerouteId }: BatchMeasurementResponse = await res.json()
|
||||
|
||||
onPhase?.('testing')
|
||||
for (const node of TEST_NODES) {
|
||||
onProgress({ nodeId: node.id, latency: null, status: 'testing' })
|
||||
}
|
||||
@@ -165,6 +168,7 @@ export async function testAllNodes(
|
||||
// Fetch traceroute results if available
|
||||
let traceroute: TracerouteStats | null = null
|
||||
if (tracerouteId) {
|
||||
onPhase?.('traceroute')
|
||||
try {
|
||||
const trRes = await fetch(`${API_BASE}/latency/traceroute/${tracerouteId}`)
|
||||
if (trRes.ok) {
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
.compare-page {
|
||||
max-width: 1000px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
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 {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.compare-title {
|
||||
font-size: 1.75rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.75rem;
|
||||
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;
|
||||
@@ -24,24 +44,50 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.compare-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--card-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
filter: drop-shadow(var(--shadow-glow));
|
||||
.compare-inputs-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
@@ -90,19 +136,14 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compare-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.compare-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 48px;
|
||||
border-radius: 12px;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 3rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 1rem;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
|
||||
color: #fff;
|
||||
@@ -161,7 +202,7 @@
|
||||
/* Results Table */
|
||||
.results-container {
|
||||
background: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
border-radius: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
backdrop-filter: var(--glass-blur);
|
||||
@@ -176,7 +217,7 @@
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: 0.875rem 1.25rem;
|
||||
padding: 1rem 1.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
@@ -379,12 +420,37 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Share link card container */
|
||||
.compare-share-container {
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
/* Comparison Stats Grid - two column layout for A vs B */
|
||||
.comparison-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
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 {
|
||||
@@ -399,11 +465,28 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compare-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
.hero-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.compare-inputs-wrapper {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
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 {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
@@ -415,6 +498,10 @@
|
||||
margin: -0.5rem 0;
|
||||
}
|
||||
|
||||
.comparison-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.compare-ip-info-cards,
|
||||
.compare-score-cards {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -8,14 +8,31 @@ import ExpirationBanner from './ExpirationBanner'
|
||||
import ShareLinkCard from './ShareLinkCard'
|
||||
import IpInfoCard from './IpInfoCard'
|
||||
import ScoreCard from './ScoreCard'
|
||||
import TestProgressBar, { TestPhase } from './TestProgressBar'
|
||||
import './ComparePage.css'
|
||||
|
||||
const IP_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/
|
||||
const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/
|
||||
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 {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
|
||||
const normalized = normalizeTarget(value)
|
||||
return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
|
||||
}
|
||||
|
||||
export default function ComparePage() {
|
||||
@@ -39,6 +56,7 @@ export default function ComparePage() {
|
||||
const [ipInfoB, setIpInfoB] = useState<IpInfo | null>(null)
|
||||
const [tracerouteA, setTracerouteA] = useState<TracerouteStats | null>(null)
|
||||
const [tracerouteB, setTracerouteB] = useState<TracerouteStats | null>(null)
|
||||
const [phase, setPhase] = useState<TestPhase>('idle')
|
||||
|
||||
useEffect(() => {
|
||||
if (!resultId) {
|
||||
@@ -126,10 +144,10 @@ export default function ComparePage() {
|
||||
const handleCompare = async () => {
|
||||
if (isReadOnly) return
|
||||
|
||||
const trimmedA = targetA.trim()
|
||||
const trimmedB = targetB.trim()
|
||||
const aValid = isValidTarget(trimmedA)
|
||||
const bValid = isValidTarget(trimmedB)
|
||||
const normalizedA = normalizeTarget(targetA)
|
||||
const normalizedB = normalizeTarget(targetB)
|
||||
const aValid = isValidTarget(targetA)
|
||||
const bValid = isValidTarget(targetB)
|
||||
|
||||
setErrors({
|
||||
a: aValid ? '' : t('无效的目标', 'Invalid target'),
|
||||
@@ -138,7 +156,12 @@ export default function ComparePage() {
|
||||
|
||||
if (!aValid || !bValid) return
|
||||
|
||||
// Update display to show normalized values
|
||||
setTargetA(normalizedA)
|
||||
setTargetB(normalizedB)
|
||||
|
||||
setTesting(true)
|
||||
setPhase('init')
|
||||
setResultsA(new Map())
|
||||
setResultsB(new Map())
|
||||
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 } }>()
|
||||
|
||||
try {
|
||||
const [resA, resB] = await Promise.all([
|
||||
testAllNodes(trimmedA, (res) => {
|
||||
// Test targets sequentially to avoid GlobalPing API rate limits
|
||||
const resA = await testAllNodes(
|
||||
normalizedA,
|
||||
(res) => {
|
||||
setResultsA(prev => new Map(prev).set(res.nodeId, res))
|
||||
if (res.status === 'success' || res.status === 'failed') {
|
||||
leftResults.set(res.nodeId, {
|
||||
@@ -164,8 +189,16 @@ export default function ComparePage() {
|
||||
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))
|
||||
if (res.status === 'success' || res.status === 'failed') {
|
||||
rightResults.set(res.nodeId, {
|
||||
@@ -175,8 +208,8 @@ export default function ComparePage() {
|
||||
stats: res.stats
|
||||
})
|
||||
}
|
||||
})
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
if (resA.resolvedAddress) {
|
||||
setResolvedIpA(resA.resolvedAddress)
|
||||
@@ -206,22 +239,29 @@ export default function ComparePage() {
|
||||
})
|
||||
|
||||
try {
|
||||
setPhase('saving')
|
||||
const { shareUrl: url } = await saveResult({
|
||||
type: 'compare',
|
||||
input: { leftTarget: trimmedA, rightTarget: trimmedB },
|
||||
input: { leftTarget: normalizedA, rightTarget: normalizedB },
|
||||
results: { left, right },
|
||||
ipInfo: { left: resA.ipInfo ?? null, right: resB.ipInfo ?? null },
|
||||
traceroute: { left: resA.traceroute ?? null, right: resB.traceroute ?? null }
|
||||
})
|
||||
setShareUrl(url)
|
||||
setShowShareModal(true)
|
||||
setPhase('complete')
|
||||
} catch (e) {
|
||||
console.error('Failed to save result:', e)
|
||||
setPhase('complete')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Comparison test failed:', e)
|
||||
setPhase('complete')
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setTesting(false)
|
||||
setPhase('idle')
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +322,33 @@ export default function ComparePage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="compare-title">{t('延迟对比', 'Latency Comparison')}</h1>
|
||||
<p className="compare-subtitle">
|
||||
@@ -289,28 +356,7 @@ export default function ComparePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="compare-inputs readonly">
|
||||
<div className="readonly-input-group">
|
||||
<span className="readonly-label">{t('目标 A', 'Target A')}</span>
|
||||
<span className="readonly-value">{targetA}</span>
|
||||
{ipInfoA && <IpInfoCard info={ipInfoA} compact />}
|
||||
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact />}
|
||||
</div>
|
||||
<div className="readonly-vs">VS</div>
|
||||
<div className="readonly-input-group">
|
||||
<span className="readonly-label">{t('目标 B', 'Target B')}</span>
|
||||
<span className="readonly-value">{targetB}</span>
|
||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
||||
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
|
||||
</div>
|
||||
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="compare-inputs">
|
||||
<div className="compare-inputs-wrapper">
|
||||
<div className="input-group">
|
||||
<label className="input-label">{t('目标 A', 'Target A')}</label>
|
||||
<input
|
||||
@@ -322,6 +368,9 @@ export default function ComparePage() {
|
||||
/>
|
||||
{errors.a && <span className="input-error">{errors.a}</span>}
|
||||
</div>
|
||||
|
||||
<div className="input-vs-divider">VS</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label className="input-label">{t('目标 B', 'Target B')}</label>
|
||||
<input
|
||||
@@ -335,7 +384,6 @@ export default function ComparePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="compare-actions">
|
||||
<button
|
||||
className="compare-button"
|
||||
onClick={handleCompare}
|
||||
@@ -350,25 +398,35 @@ export default function ComparePage() {
|
||||
t('开始对比', 'Start Comparison')
|
||||
)}
|
||||
</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>
|
||||
|
||||
{(shareUrl || resultsA.size > 0 || resultsB.size > 0) && !testing && (
|
||||
<div className="compare-share-container">
|
||||
{(ipInfoA || ipInfoB) && (
|
||||
<div className="compare-ip-info-cards">
|
||||
{ipInfoA && <IpInfoCard info={ipInfoA} compact className="compare-ip-card" />}
|
||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact className="compare-ip-card" />}
|
||||
</div>
|
||||
)}
|
||||
{(resultsA.size > 0 || resultsB.size > 0) && (
|
||||
<div className="compare-score-cards">
|
||||
{resultsA.size > 0 && <ScoreCard results={Array.from(resultsA.values())} ipInfo={ipInfoA} traceroute={tracerouteA} compact className="compare-score-card" />}
|
||||
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact className="compare-score-card" />}
|
||||
</div>
|
||||
)}
|
||||
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
||||
{(resultsA.size > 0 || resultsB.size > 0) && !testing && (
|
||||
<div className="comparison-stats-grid">
|
||||
<div className="comparison-column">
|
||||
<div className="comparison-column-header">{targetA || 'Target A'}</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 || 'Target B'}</div>
|
||||
{resultsB.size > 0 && <ScoreCard results={Array.from(resultsB.values())} ipInfo={ipInfoB} traceroute={tracerouteB} compact />}
|
||||
{ipInfoB && <IpInfoCard info={ipInfoB} compact />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shareUrl && !testing && <ShareLinkCard shareUrl={shareUrl} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ import ExpirationBanner from './ExpirationBanner'
|
||||
import ShareLinkCard from './ShareLinkCard'
|
||||
import IpInfoCard from './IpInfoCard'
|
||||
import ScoreCard from './ScoreCard'
|
||||
import TestProgressBar, { TestPhase } from './TestProgressBar'
|
||||
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'
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -27,6 +28,7 @@ export default function HomePage() {
|
||||
const [resolvedIp, setResolvedIp] = useState<string | null>(null)
|
||||
const [ipInfo, setIpInfo] = useState<IpInfo | null>(null)
|
||||
const [traceroute, setTraceroute] = useState<TracerouteStats | null>(null)
|
||||
const [phase, setPhase] = useState<TestPhase>('idle')
|
||||
const { t } = useLanguage()
|
||||
|
||||
// Load saved result if viewing shared link
|
||||
@@ -77,6 +79,7 @@ export default function HomePage() {
|
||||
|
||||
const handleTest = useCallback(async (testTarget: string) => {
|
||||
setTesting(true)
|
||||
setPhase('init')
|
||||
setResults(new Map())
|
||||
setSelectedNodeId(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 { 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))
|
||||
if (result.status === 'success' || result.status === 'failed') {
|
||||
finalResults.push({
|
||||
@@ -97,7 +102,9 @@ export default function HomePage() {
|
||||
stats: result.stats
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
(newPhase) => setPhase(newPhase)
|
||||
)
|
||||
|
||||
if (resolvedAddress) {
|
||||
setResolvedIp(resolvedAddress)
|
||||
@@ -113,6 +120,7 @@ export default function HomePage() {
|
||||
|
||||
// Save result and get share URL
|
||||
try {
|
||||
setPhase('saving')
|
||||
const { shareUrl: url } = await saveResult({
|
||||
type: 'single',
|
||||
input: { target: testTarget },
|
||||
@@ -122,11 +130,16 @@ export default function HomePage() {
|
||||
})
|
||||
setShareUrl(url)
|
||||
setShowShareModal(true)
|
||||
setPhase('complete')
|
||||
} catch (e) {
|
||||
console.error('Failed to save result:', e)
|
||||
setPhase('complete')
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setTesting(false)
|
||||
setPhase('idle')
|
||||
}, 1500)
|
||||
}, [])
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||
@@ -152,54 +165,90 @@ export default function HomePage() {
|
||||
|
||||
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 (
|
||||
<>
|
||||
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
||||
|
||||
<div className="hero-card">
|
||||
<p className="app-description">
|
||||
{t('从全球各地测试到任意IP地址或域名的网络延迟', 'Test network latency from global locations to any IP address or domain')}
|
||||
</p>
|
||||
|
||||
{isReadOnly && <ExpirationBanner shareUrl={currentUrl} />}
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="readonly-input-container">
|
||||
<div className="readonly-input">
|
||||
<span className="readonly-label">{t('测试目标', 'Test Target')}</span>
|
||||
<span className="readonly-value">{target}</span>
|
||||
</div>
|
||||
{ipInfo && <IpInfoCard info={ipInfo} compact />}
|
||||
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact />}
|
||||
<div className="readonly-badge">{t('只读模式', 'View Only')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<IpInput onTest={handleTest} testing={testing} />
|
||||
{(ipInfo || (resolvedIp && target !== resolvedIp) || shareUrl || results.size > 0) && (
|
||||
<div className="test-result-info">
|
||||
{ipInfo ? (
|
||||
<IpInfoCard info={ipInfo} compact />
|
||||
) : resolvedIp && target !== resolvedIp ? (
|
||||
<div className="resolved-ip-info">
|
||||
<span className="resolved-ip-label">{t('解析IP', 'Resolved IP')}</span>
|
||||
<span className="resolved-ip-value">{resolvedIp}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{results.size > 0 && <ScoreCard results={Array.from(results.values())} ipInfo={ipInfo} traceroute={traceroute} compact testing={testing} />}
|
||||
{shareUrl && <ShareLinkCard shareUrl={shareUrl} />}
|
||||
</div>
|
||||
)}
|
||||
<TestProgressBar
|
||||
current={Array.from(results.values()).filter(r => r.status === 'success' || r.status === 'failed').length}
|
||||
total={TEST_NODES.length}
|
||||
phase={phase}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="layout-container">
|
||||
{/* Map Section - The Visual Hero */}
|
||||
<div className="map-section">
|
||||
<LatencyMap
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
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
|
||||
results={results}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
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 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 {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
return IP_REGEX.test(trimmed) || DOMAIN_REGEX.test(trimmed)
|
||||
const normalized = normalizeTarget(value)
|
||||
return IP_REGEX.test(normalized) || DOMAIN_REGEX.test(normalized)
|
||||
}
|
||||
|
||||
export default function IpInput({ onTest, testing }: IpInputProps) {
|
||||
@@ -31,13 +47,14 @@ export default function IpInput({ onTest, testing }: IpInputProps) {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = target.trim()
|
||||
if (!isValidTarget(trimmed)) {
|
||||
if (!isValidTarget(target)) {
|
||||
setError(t('无效的IP地址或域名', 'Invalid IP address or domain'))
|
||||
return
|
||||
}
|
||||
const normalized = normalizeTarget(target)
|
||||
setTarget(normalized) // Update display to show normalized value
|
||||
setError('')
|
||||
onTest(trimmed)
|
||||
onTest(normalized)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
.map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
height: 500px;
|
||||
margin: 0 auto 3rem;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
@@ -152,6 +151,7 @@
|
||||
@media (max-width: 768px) {
|
||||
.map-container {
|
||||
height: 400px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.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 { TEST_NODES, LatencyResult, getLatencyColor } from '@shared/types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
@@ -14,6 +14,27 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
|
||||
const globeEl = useRef<GlobeMethods | undefined>(undefined)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
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(() => {
|
||||
if (globeEl.current) {
|
||||
@@ -73,6 +94,8 @@ export default function LatencyMap({ results, selectedNodeId, onNodeSelect }: La
|
||||
<div className="map-container" ref={containerRef}>
|
||||
<Globe
|
||||
ref={globeEl}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
|
||||
backgroundImageUrl="//unpkg.com/three-globe/example/img/night-sky.png"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.results-panel {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
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 {
|
||||
flex: 1;
|
||||
padding: 3rem 2rem;
|
||||
max-width: 1200px;
|
||||
padding: 2rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -206,6 +206,41 @@ body {
|
||||
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-container {
|
||||
display: flex;
|
||||
@@ -330,3 +365,149 @@ body {
|
||||
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 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 {
|
||||
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 {
|
||||
@@ -536,16 +553,17 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
|
||||
return jsonResponse({ error: 'target is required' }, 400)
|
||||
}
|
||||
|
||||
const trimmedTarget = target.trim().toLowerCase()
|
||||
|
||||
if (!isValidTarget(trimmedTarget)) {
|
||||
if (!isValidTarget(target)) {
|
||||
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)
|
||||
let tracerouteTarget = trimmedTarget
|
||||
if (!isIPAddress(trimmedTarget)) {
|
||||
const resolvedIP = await resolveDomainToIP(trimmedTarget)
|
||||
let tracerouteTarget = normalizedTarget
|
||||
if (!isIPAddress(normalizedTarget)) {
|
||||
const resolvedIP = await resolveDomainToIP(normalizedTarget)
|
||||
if (resolvedIP) {
|
||||
tracerouteTarget = resolvedIP
|
||||
}
|
||||
@@ -553,7 +571,7 @@ async function handleCreateMeasurement(request: Request, env: Env): Promise<Resp
|
||||
|
||||
// Create both ping and traceroute measurements in parallel
|
||||
const [measurementId, tracerouteId] = await Promise.all([
|
||||
createBatchMeasurement(trimmedTarget, env),
|
||||
createBatchMeasurement(normalizedTarget, env),
|
||||
createMtrMeasurement(tracerouteTarget, env)
|
||||
])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "latency-test"
|
||||
main = "dist/worker/index.js"
|
||||
compatibility_date = "2025-12-25"
|
||||
compatibility_date = "2025-12-28"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[assets]
|
||||
|
||||
Reference in New Issue
Block a user