= ({ onHistoryUpdate }) =>
{p.service || 'Unknown'}
{/* Latency RTT Column */}
- {p.latencyMs} ms
+
+
+ {p.latencyMs === null && (
+
+ {p.description || (p.status === 'not-tested' ? 'No probe ran for this port.' : 'No response latency could be measured.')}
+
+ )}
+
{/* Hyperlink Column for HTTP/HTTPS scan results */}
diff --git a/src/components/PrivacySafetyModal.tsx b/src/components/PrivacySafetyModal.tsx
index e5c7530..e265df1 100644
--- a/src/components/PrivacySafetyModal.tsx
+++ b/src/components/PrivacySafetyModal.tsx
@@ -39,9 +39,9 @@ export const THIRD_PARTY_DISCLOSURES: { host: string; receives: string }[] = [
'the other a valid one. Only the resolvers see these queries.',
},
{
- host: 'ipwho.is / ipapi.co / freeipapi.com',
+ host: 'ipwho.is / ipapi.co',
receives:
- 'Your public IP when you open the GeoIP tool, and every IP or domain you look up or trace.',
+ 'Your public IP when you run a GeoIP lookup, and every IP or domain you look up.',
},
{
host: '1.1.1.1, one.one.one.one, dns.quad9.net, doh.opendns.com, en.wikipedia.org',
@@ -88,7 +88,7 @@ export const THIRD_PARTY_DISCLOSURES: { host: string; receives: string }[] = [
},
{
host: 'basemaps.cartocdn.com',
- receives: 'The map area you view, which reveals the approximate location of a traced target.',
+ receives: 'The map area you view, which reveals approximate endpoint locations in Edge Path Explorer.',
},
{
host: 'openstreetmap.org',
@@ -200,14 +200,9 @@ export const PrivacySafetyModal: React.FC = ({ isOpen,
- {/* The honest part.
- A diagnostic tool cannot measure a network without touching it.
- Claiming "we do not transmit any IP addresses, scan targets or
- domain names" was flatly untrue: running a trace sends the target
- IP to two GeoIP providers, opening the GeoIP tab sends your own
- public IP before you click anything, and a speed test moves tens
- of megabytes through Cloudflare. Listing that is more useful to a
- privacy-conscious user than a reassuring sentence. */}
+ {/* Diagnostics contact external hosts. GeoIP queries send addresses to
+ lookup providers and speed tests move data through Cloudflare.
+ The list explains those requests before a user runs a tool. */}
What leaves your browser, and to whom
diff --git a/src/components/ResponsibleNetworkingModal.tsx b/src/components/ResponsibleNetworkingModal.tsx
index 544c702..0b52c2a 100644
--- a/src/components/ResponsibleNetworkingModal.tsx
+++ b/src/components/ResponsibleNetworkingModal.tsx
@@ -101,7 +101,7 @@ export const ResponsibleNetworkingModal: React.FCEthical Scanning & Diagnostic Standard
- Port scanning, ping testing, socket probing, and traceroute diagnostic tools generate active TCP/UDP or HTTP network requests. You must ensure you have legal authorization before executing tests against external hosts or infrastructure.
+ Port scanning, ping testing, and socket probing tools generate active network requests. You must ensure you have legal authorization before executing tests against external hosts or infrastructure.
diff --git a/src/components/SpeedTest.tsx b/src/components/SpeedTest.tsx
index d514d51..a9dc074 100644
--- a/src/components/SpeedTest.tsx
+++ b/src/components/SpeedTest.tsx
@@ -1,4 +1,5 @@
-import React, { useState } from 'react';
+import type { ToolLaunch } from '../types';
+import React, { useState, useEffect, useRef } from 'react';
import {
Gauge,
Play,
@@ -18,10 +19,11 @@ import { ResponsibleNetworkingModal, isResponsibleNetworkingAccepted } from './R
import { MetricValue, FailureNotice, displayMetric } from './MetricValue';
interface SpeedTestProps {
+ launch?: ToolLaunch;
onHistoryUpdate: () => void;
}
-export const SpeedTest: React.FC = ({ onHistoryUpdate }) => {
+export const SpeedTest: React.FC = ({ onHistoryUpdate, launch }) => {
const [isRunning, setIsRunning] = useState(false);
const [stage, setStage] = useState<'idle' | 'ping' | 'download' | 'upload' | 'complete'>('idle');
const [progress, setProgress] = useState(0);
@@ -127,6 +129,17 @@ export const SpeedTest: React.FC = ({ onHistoryUpdate }) => {
executeTest();
};
+ const launchedIdRef = useRef(null);
+ useEffect(() => {
+ if (launch === undefined || launchedIdRef.current === launch.id) return;
+ // Defer until mount effects settle so StrictMode cannot cancel the only run.
+ const timer = window.setTimeout(() => {
+ launchedIdRef.current = launch.id;
+ void startTest();
+ }, 0);
+ return () => window.clearTimeout(timer);
+ });
+
const getBufferbloatColor = (grade?: string | null) => {
switch (grade) {
case 'A+':
diff --git a/src/components/TracertMap.tsx b/src/components/TracertMap.tsx
deleted file mode 100644
index 48248f6..0000000
--- a/src/components/TracertMap.tsx
+++ /dev/null
@@ -1,526 +0,0 @@
-import React, { useEffect, useRef, useState } from 'react';
-import L from 'leaflet';
-import { TracertHop } from '../types';
-import {
- Play,
- Pause,
- ChevronLeft,
- ChevronRight,
- Maximize2,
- Navigation,
- Radio,
-} from 'lucide-react';
-
-/** Creates an element with text content applied safely. */
-function el(tag: string, cssText: string, text?: string | number): HTMLElement {
- const node = document.createElement(tag);
- node.style.cssText = cssText;
- if (text !== undefined) node.textContent = String(text);
- return node;
-}
-
-/**
- * Marker badge for a hop. Returns a DOM node, which Leaflet accepts in place of
- * an HTML string for `divIcon({ html })`.
- */
-function buildHopBadge(hopNumber: number, colour: string, isActive: boolean): HTMLElement {
- const size = isActive ? '34px' : '26px';
- const badge = el(
- 'div',
- `position:relative;width:${size};height:${size};background:${colour};` +
- 'border:2px solid rgba(255,255,255,0.9);border-radius:50%;display:flex;' +
- 'align-items:center;justify-content:center;color:#000;font-weight:800;' +
- `font-size:${isActive ? '12px' : '10px'};font-family:monospace;` +
- `box-shadow:0 0 ${isActive ? '20px' : '10px'} ${colour};cursor:pointer;` +
- 'transition:all 0.3s ease;',
- hopNumber,
- );
-
- if (isActive) {
- badge.appendChild(
- el(
- 'div',
- `position:absolute;inset:-8px;border:2px solid ${colour};border-radius:50%;` +
- 'animation:pulseRing 1.5s infinite;',
- ),
- );
- }
- return badge;
-}
-
-/**
- * Popup body for a hop.
- *
- * Every field goes through `textContent`. `hop.hostname` is the raw string the
- * user typed into the target box, and `hop.city` / `hop.country` / `hop.isp`
- * are third-party GeoIP responses β none of it can be trusted as markup.
- */
-function buildHopPopup(hop: TracertHop, colour: string): HTMLElement {
- const wrap = el(
- 'div',
- 'font-family:monospace;color:#f8fafc;background:#0f172a;padding:10px;' +
- 'border-radius:8px;border:1px solid #334155;min-width:180px;',
- );
-
- wrap.appendChild(
- el(
- 'div',
- `color:${colour};font-weight:bold;font-size:13px;margin-bottom:4px;`,
- `Hop #${hop.hop} β’ ${String(hop.nodeType).toUpperCase()}`,
- ),
- );
- wrap.appendChild(
- el(
- 'div',
- 'font-weight:bold;font-size:12px;color:#fff;margin-bottom:2px;',
- hop.hostname || hop.ip,
- ),
- );
- wrap.appendChild(el('div', 'color:#94a3b8;font-size:11px;', `IP: ${hop.ip}`));
- wrap.appendChild(
- el(
- 'div',
- 'color:#38bdf8;font-size:11px;',
- `RTT: ${hop.avgRtt > 0 ? `${hop.avgRtt} ms` : 'Timeout'}`,
- ),
- );
- wrap.appendChild(
- el(
- 'div',
- 'color:#cbd5e1;font-size:11px;margin-top:4px;',
- `π ${hop.city}, ${hop.country}`,
- ),
- );
- wrap.appendChild(el('div', 'color:#64748b;font-size:10px;', hop.isp));
- return wrap;
-}
-
-interface TracertMapProps {
- hops: TracertHop[];
- activeHopNumber: number | null;
- onSelectHop: (hopNumber: number) => void;
- isTracing: boolean;
-}
-
-export const TracertMap: React.FC = ({
- hops,
- activeHopNumber,
- onSelectHop,
- isTracing,
-}) => {
- const mapContainerRef = useRef(null);
- const mapInstanceRef = useRef(null);
- const markersRef = useRef<{ [hopNum: number]: L.Marker }>({});
- const polylineRef = useRef(null);
-
- const [autoFollow, setAutoFollow] = useState(true);
- const [isPlayingRoute, setIsPlayingRoute] = useState(false);
- const [mapMode, setMapMode] = useState<'leaflet' | 'vector'>('leaflet');
-
- // Initialize Leaflet Map
- useEffect(() => {
- if (mapMode !== 'leaflet' || !mapContainerRef.current) return;
-
- if (!mapInstanceRef.current) {
- const map = L.map(mapContainerRef.current, {
- center: [20, 0],
- zoom: 2,
- minZoom: 1,
- maxZoom: 18,
- zoomControl: false,
- });
-
- // CartoDB Dark Matter tile layer
- L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
- attribution: '© OpenStreetMap © CARTO ',
- subdomains: 'abcd',
- maxZoom: 19,
- }).addTo(map);
-
- L.control.zoom({ position: 'bottomright' }).addTo(map);
-
- mapInstanceRef.current = map;
- }
-
- return () => {
- if (mapInstanceRef.current) {
- mapInstanceRef.current.remove();
- mapInstanceRef.current = null;
- }
- };
- }, [mapMode]);
-
- // Update Markers and Polylines whenever hops or activeHopNumber changes
- useEffect(() => {
- if (mapMode !== 'leaflet' || !mapInstanceRef.current) return;
-
- const map = mapInstanceRef.current;
-
- // Clear existing markers
- Object.values(markersRef.current).forEach((m) => map.removeLayer(m));
- markersRef.current = {};
-
- if (polylineRef.current) {
- map.removeLayer(polylineRef.current);
- polylineRef.current = null;
- }
-
- if (hops.length === 0) return;
-
- const latLngs: [number, number][] = [];
-
- hops.forEach((hop) => {
- if (!hop.lat || !hop.lng) return;
- const coords: [number, number] = [hop.lat, hop.lng];
- latLngs.push(coords);
-
- const isActive = hop.hop === activeHopNumber;
-
- // Color based on node type
- let badgeBg = '#06b6d4'; // Cyan
- if (hop.nodeType === 'client' || hop.nodeType === 'gateway') badgeBg = '#10b981'; // Emerald
- else if (hop.nodeType === 'destination') badgeBg = '#f43f5e'; // Rose
- else if (hop.nodeType === 'backbone') badgeBg = '#8b5cf6'; // Purple
-
- if (isActive) badgeBg = '#eab308'; // Gold
-
- // Marker badge, built as a DOM node rather than an HTML string so the hop
- // label can never be interpreted as markup.
- const customIcon = L.divIcon({
- className: 'custom-tracert-marker',
- html: buildHopBadge(hop.hop, badgeBg, isActive),
- iconSize: [isActive ? 34 : 26, isActive ? 34 : 26],
- iconAnchor: [isActive ? 17 : 13, isActive ? 17 : 13],
- });
-
- const marker = L.marker(coords, { icon: customIcon }).addTo(map);
-
- // Popup details.
- //
- // Built as DOM nodes with `textContent`, never as an interpolated HTML
- // string. `L.divIcon({html})` and `bindPopup(html)` are innerHTML sinks,
- // and every field below is attacker-influenced: `hostname` is the raw
- // target the user typed, while `city`, `country` and `isp` come verbatim
- // from third-party GeoIP JSON. Interpolating them meant a target named
- // `x"> ` executed script in this origin.
- marker.bindPopup(buildHopPopup(hop, badgeBg), { className: 'tracert-dark-popup' });
-
- marker.on('click', () => {
- onSelectHop(hop.hop);
- });
-
- markersRef.current[hop.hop] = marker;
- });
-
- // Draw glowing polyline connecting hops
- if (latLngs.length > 1) {
- polylineRef.current = L.polyline(latLngs, {
- color: '#06b6d4',
- weight: 3,
- opacity: 0.8,
- dashArray: '8, 8',
- }).addTo(map);
- }
-
- // Follow Active Hop or Latest Hop
- if (autoFollow && activeHopNumber && markersRef.current[activeHopNumber]) {
- const activeHopObj = hops.find((h) => h.hop === activeHopNumber);
- if (activeHopObj && activeHopObj.lat && activeHopObj.lng) {
- map.flyTo([activeHopObj.lat, activeHopObj.lng], Math.max(map.getZoom(), 4), {
- duration: 0.8,
- });
- }
- }
- }, [hops, activeHopNumber, autoFollow, mapMode, onSelectHop]);
-
- // Handle Play Route Animation Step Loop
- useEffect(() => {
- if (!isPlayingRoute || hops.length === 0) return;
-
- let currentIdx = 0;
- const interval = setInterval(() => {
- const nextHop = hops[currentIdx];
- if (nextHop) {
- onSelectHop(nextHop.hop);
- }
- currentIdx = (currentIdx + 1) % hops.length;
- }, 1500);
-
- return () => clearInterval(interval);
- }, [isPlayingRoute, hops, onSelectHop]);
-
- const fitAllBounds = () => {
- if (!mapInstanceRef.current || hops.length === 0) return;
-
- const validCoords: [number, number][] = hops
- .filter((h) => h.lat && h.lng)
- .map((h) => [h.lat, h.lng]);
-
- if (validCoords.length > 0) {
- const bounds = L.latLngBounds(validCoords);
- mapInstanceRef.current.fitBounds(bounds, { padding: [40, 40] });
- }
- };
-
- const handleStepBack = () => {
- if (!activeHopNumber || activeHopNumber <= 1) return;
- onSelectHop(activeHopNumber - 1);
- };
-
- const handleStepForward = () => {
- if (!activeHopNumber || activeHopNumber >= hops.length) return;
- onSelectHop(activeHopNumber + 1);
- };
-
- const activeHop = hops.find((h) => h.hop === activeHopNumber) || hops[hops.length - 1];
-
- return (
-
- {/* Map Control Header */}
-
-
-
-
- Live Route Geo-Hop Map
-
- {isTracing && (
-
-
- Tracing Hop {hops.length}...
-
- )}
-
-
- {/* Action Toolbar */}
-
- {/* Step Back / Play / Step Forward */}
-
-
-
-
-
-
setIsPlayingRoute(!isPlayingRoute)}
- disabled={hops.length === 0}
- className={`px-2.5 py-1 text-xs font-mono font-bold rounded-lg flex items-center space-x-1 transition-colors ${
- isPlayingRoute
- ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40'
- : 'bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30'
- }`}
- >
- {isPlayingRoute ? (
- <>
-
- Pause
- >
- ) : (
- <>
-
- Play Path
- >
- )}
-
-
-
= hops.length}
- className="p-1.5 text-slate-400 hover:text-cyan-400 disabled:opacity-30 rounded-lg transition-colors"
- title="Next Hop"
- >
-
-
-
-
- {/* Auto-Follow Hop Toggle */}
-
setAutoFollow(!autoFollow)}
- className={`px-2.5 py-1.5 rounded-xl text-xs font-mono font-semibold border flex items-center space-x-1.5 transition-all ${
- autoFollow
- ? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40 shadow-sm'
- : 'bg-slate-800 text-slate-400 border-slate-700'
- }`}
- title="Auto-pan map to active hop"
- >
-
- Follow Hop
-
-
- {/* Fit Bounds */}
-
-
-
-
- {/* View Mode Switcher */}
-
- setMapMode('leaflet')}
- className={`px-2 py-1 rounded-lg text-[10px] font-mono uppercase font-bold transition-all ${
- mapMode === 'leaflet'
- ? 'bg-cyan-500 text-black shadow-md'
- : 'text-slate-400 hover:text-slate-200'
- }`}
- >
- Leaflet
-
- setMapMode('vector')}
- className={`px-2 py-1 rounded-lg text-[10px] font-mono uppercase font-bold transition-all ${
- mapMode === 'vector'
- ? 'bg-cyan-500 text-black shadow-md'
- : 'text-slate-400 hover:text-slate-200'
- }`}
- >
- Vector
-
-
-
-
-
- {/* Map Stage Container */}
-
- {mapMode === 'leaflet' ? (
-
- ) : (
- /* High-Tech Vector Projection Map Fallback */
-
- {/* World Grid Lines Background */}
-
-
-
-
-
-
-
-
-
- {/* SVG Arc Connecting Paths */}
- {hops.length > 0 ? (
-
- {hops.map((h, idx) => {
- if (idx === 0) return null;
- const prev = hops[idx - 1];
- // Map Lat/Lng to % offsets
- const x1 = ((prev.lng + 180) / 360) * 100;
- const y1 = ((90 - prev.lat) / 180) * 100;
- const x2 = ((h.lng + 180) / 360) * 100;
- const y2 = ((90 - h.lat) / 180) * 100;
-
- return (
-
-
-
- );
- })}
-
- ) : null}
-
- {/* Vector Nodes */}
- {hops.map((h) => {
- const x = ((h.lng + 180) / 360) * 100;
- const y = ((90 - h.lat) / 180) * 100;
- const isActive = h.hop === activeHopNumber;
-
- return (
-
onSelectHop(h.hop)}
- style={{ left: `${x}%`, top: `${y}%` }}
- className={`absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer group transition-all z-10 ${
- isActive ? 'scale-125 z-20' : 'hover:scale-110'
- }`}
- >
-
- {h.hop}
-
-
- {/* Hover Tooltip */}
-
- #{h.hop} {h.hostname || h.ip} ({h.city}, {h.country})
-
-
- );
- })}
-
- {hops.length === 0 && (
-
- Enter a host target above and click "Run Route Traceroute" to project map path.
-
- )}
-
- )}
-
- {/* Active Hop Overlay Card Drawer */}
- {activeHop && (
-
-
-
-
-
- Hop #{activeHop.hop} Details
-
-
-
- {activeHop.nodeType}
-
-
-
-
-
- {activeHop.hostname || activeHop.ip}
-
-
IP Address: {activeHop.ip}
-
-
- Response Latency:
- 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
- {activeHop.avgRtt > 0 ? `${activeHop.avgRtt} ms` : 'Timed Out'}
-
-
-
-
- Location:
-
- π {activeHop.city}, {activeHop.country}
-
-
-
-
- ISP / AS:
-
- {activeHop.isp} ({activeHop.asn})
-
-
-
-
- )}
-
-
- );
-};
diff --git a/src/components/TracertVisualizer.tsx b/src/components/TracertVisualizer.tsx
deleted file mode 100644
index b968305..0000000
--- a/src/components/TracertVisualizer.tsx
+++ /dev/null
@@ -1,503 +0,0 @@
-import React, { useState } from 'react';
-import {
- AlertTriangle,
- GitCommit,
- Play,
- RefreshCw,
- Copy,
- Check,
- Terminal,
- Activity,
- ListFilter,
-} from 'lucide-react';
-import {
- ResponsiveContainer,
- AreaChart,
- Area,
- XAxis,
- YAxis,
- Tooltip,
- CartesianGrid,
-} from 'recharts';
-import { TracertHop, TracertResult, HistoryItem } from '../types';
-import { TRACERT_PRESETS, executeTraceroute } from '../utils/tracert';
-import { TracertMap } from './TracertMap';
-import { saveHistoryItem } from '../utils/storage';
-import { ResponsibleNetworkingModal, isResponsibleNetworkingAccepted } from './ResponsibleNetworkingModal';
-
-interface TracertVisualizerProps {
- onHistoryUpdate: () => void;
-}
-
-export const TracertVisualizer: React.FC = ({ onHistoryUpdate }) => {
- const [selectedPreset, setSelectedPreset] = useState(TRACERT_PRESETS[0].target);
- const [customTarget, setCustomTarget] = useState('');
- const [protocol, setProtocol] = useState<'ICMP' | 'UDP' | 'TCP'>('ICMP');
- const [maxHops, setMaxHops] = useState(20);
-
- const [isTracing, setIsTracing] = useState(false);
- const [hops, setHops] = useState([]);
- const [activeHopNum, setActiveHopNum] = useState(null);
- const [result, setResult] = useState(null);
- const [showResponsibleModal, setShowResponsibleModal] = useState(false);
-
- const [copiedTerminal, setCopiedTerminal] = useState(false);
- const [error, setError] = useState(null);
-
- const getActiveTarget = () => {
- if (customTarget.trim()) return customTarget.trim();
- return selectedPreset;
- };
-
- const executeTrace = async () => {
- const target = getActiveTarget();
- if (!target) return;
-
- setIsTracing(true);
- setHops([]);
- setActiveHopNum(null);
- setResult(null);
- setError(null);
-
- try {
- const res = await executeTraceroute(
- target,
- protocol,
- maxHops,
- (discoveredHop, currentHops) => {
- setHops(currentHops);
- setActiveHopNum(discoveredHop.hop);
- }
- );
-
- setResult(res);
-
- // Save result to LocalStorage history
- const historyItem: HistoryItem = {
- id: res.id,
- type: 'tracert',
- timestamp: res.timestamp,
- title: `Route model: ${res.targetHost} (${res.totalHops} hops, simulated)`,
- summary: `Target IP: ${res.targetIp} | Great-circle distance: ${res.totalDistanceKm} km | SIMULATED INTERMEDIATE HOPS`,
- data: res,
- };
-
- saveHistoryItem(historyItem);
- onHistoryUpdate();
- } catch (err) {
- console.error('Traceroute error:', err);
- setError(
- err instanceof Error
- ? err.message
- : 'The trace could not complete.',
- );
- } finally {
- setIsTracing(false);
- }
- };
-
- const handleStartTrace = () => {
- if (!isResponsibleNetworkingAccepted()) {
- setShowResponsibleModal(true);
- return;
- }
- executeTrace();
- };
-
- // Build standard CLI traceroute text format for copy/export
- const getCliText = () => {
- const target = getActiveTarget();
- let text = `Tracing route to ${target} [${result?.targetIp || '...'}] over a maximum of ${maxHops} hops:\n\n`;
-
- hops.forEach((h) => {
- if (h.status === 'timeout') {
- text += ` ${h.hop.toString().padStart(2, ' ')} * * * Request timed out.\n`;
- } else {
- const r1 = `${h.rtt1} ms`.padStart(7, ' ');
- const r2 = `${h.rtt2} ms`.padStart(7, ' ');
- const r3 = `${h.rtt3} ms`.padStart(7, ' ');
- text += ` ${h.hop.toString().padStart(2, ' ')} ${r1} ${r2} ${r3} ${h.hostname || h.ip} [${h.ip}] (${h.city}, ${h.country})\n`;
- }
- });
-
- if (result) {
- text += `\nTrace complete. Total hops: ${result.totalHops}, Total distance: ${result.totalDistanceKm} km.\n`;
- }
-
- return text;
- };
-
- const handleCopyTerminal = () => {
- navigator.clipboard.writeText(getCliText());
- setCopiedTerminal(true);
- setTimeout(() => setCopiedTerminal(false), 2000);
- };
-
- return (
-
- {/* This tool cannot do what its name implies, and says so. A browser has
- no access to raw sockets and cannot set an IP TTL, so there is no way
- to elicit a reply from an intermediate router. Hops 1..n-1 below are
- generated. Phase 3 replaces this with the Edge Path Explorer, which
- measures things the browser genuinely can observe. */}
-
-
-
-
- Simulated route β the intermediate hops are not measured
-
-
- Browsers cannot send ICMP packets or set an IP TTL, so no web page can perform a real
- traceroute. The first and last hops here are grounded in a real DNS resolution and a
- real geolocation lookup; everything between them is generated to illustrate a plausible
- path. Do not cite these hop addresses, ISPs or timings as evidence β they are marked as
- simulated in exports for the same reason.
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
- {/* Header Banner */}
-
-
-
-
-
Route Model (Illustrative)
-
- Simulated
-
-
-
- Resolves the target, looks up the real endpoint location, and draws a plausible
- great-circle path to it. The intermediate hops are a model, not a measurement.
-
-
-
-
- {isTracing ? (
- <>
-
- Tracing Hop {hops.length}...
- >
- ) : (
- <>
-
- Run Route Traceroute
- >
- )}
-
-
-
- {/* Target & Protocol Controls Bar */}
-
-
- Target Endpoint & Preset Selection
-
-
- {/* Preset Chips */}
-
- {TRACERT_PRESETS.map((p) => {
- const isSelected = selectedPreset === p.target && !customTarget;
- return (
-
{
- setSelectedPreset(p.target);
- setCustomTarget('');
- }}
- className={`p-3 rounded-xl text-xs font-medium text-left transition-all border ${
- isSelected
- ? 'bg-cyan-500/15 text-cyan-300 border-cyan-500/40 shadow-sm'
- : 'bg-slate-800/60 hover:bg-slate-800 text-slate-300 border-slate-700/60'
- }`}
- >
-
- {p.flag}
- {p.name}
-
- {p.region}
-
- );
- })}
-
-
- {/* Custom Input & Protocol Selectors */}
-
-
-
- Or Custom Hostname / Target IP:
-
- setCustomTarget(e.target.value)}
- className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2 text-xs text-slate-200 focus:outline-none focus:border-cyan-500 font-mono"
- />
-
-
-
- Protocol:
- setProtocol(e.target.value as any)}
- className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-cyan-500 font-mono"
- >
- ICMP Echo Request
- UDP Probe (Port 33434)
- TCP SYN Probe (Port 80/443)
-
-
-
-
- Max Hop Limit:
- setMaxHops(Number(e.target.value))}
- className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-cyan-500 font-mono"
- >
- 15 Hops
- 20 Hops
- 30 Hops
-
-
-
-
-
- {/* Primary Map Visualizer Section ("Map section that follows each hop") */}
-
setActiveHopNum(hopNum)}
- isTracing={isTracing}
- />
-
- {/* Metrics Summary Strip */}
-
-
-
Target Host
-
- {getActiveTarget()}
-
-
-
-
-
Total Hops
-
- {hops.length > 0 ? hops.length : '--'}
-
-
-
-
-
Total Distance
-
- {result ? `${result.totalDistanceKm} km` : '--'}
-
-
-
-
-
Avg Route RTT
-
- {result ? `${result.avgLatencyMs} ms` : '--'}
-
-
-
-
-
End-to-End Latency
-
- {hops.length > 0 && hops[hops.length - 1].avgRtt > 0
- ? `${hops[hops.length - 1].avgRtt} ms`
- : '--'}
-
-
-
-
- {/* Latency Progression Elevation Area Chart */}
- {hops.length > 0 && (
-
-
-
- Route Latency Progression (ms per hop)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Terminal View & Hop Table */}
-
- {/* Hop Logs Table */}
-
-
-
-
- Hop Trace Sequence ({hops.length} Discovered)
-
-
-
- Click any row to focus on map
-
-
-
- {hops.length === 0 ? (
-
- No hops recorded yet. Click "Run Route Traceroute" to begin packet tracing!
-
- ) : (
-
-
-
- Hop
- Node / Hostname
- IP Address
- RTT Avg
- Location
- ISP / ASN
-
-
-
- {hops.map((h) => {
- const isSelected = h.hop === activeHopNum;
- return (
- setActiveHopNum(h.hop)}
- className={`cursor-pointer transition-colors ${
- isSelected
- ? 'bg-cyan-500/15 text-cyan-200 border-l-4 border-l-cyan-400 font-semibold'
- : 'hover:bg-slate-800/40 text-slate-300'
- }`}
- >
-
-
- {h.hop}
-
-
-
-
- {h.hostname || h.ip}
- {h.nodeType}
-
-
- {h.ip}
-
-
- {h.status === 'timeout' ? (
- * * *
- ) : (
- 120 ? 'text-amber-400 font-bold' : 'text-emerald-400 font-bold'}>
- {h.avgRtt} ms
-
- )}
-
-
-
- π {h.city}, {h.countryCode}
-
-
-
- {h.isp} ({h.asn})
-
-
- );
- })}
-
-
- )}
-
-
- {/* Terminal Output Stream */}
-
-
-
-
-
-
- CLI Terminal Stream
-
-
-
-
- {copiedTerminal ? (
- <>
-
- Copied!
- >
- ) : (
- <>
-
- Copy Text
- >
- )}
-
-
-
-
-
-
-
- NetReady TRACERT Engine
- Ready
-
-
-
-
- setShowResponsibleModal(false)}
- onConfirm={() => {
- setShowResponsibleModal(false);
- executeTrace();
- }}
- />
-
- );
-};
diff --git a/src/components/TriagePanel.tsx b/src/components/TriagePanel.tsx
index 54f57f4..f897fcf 100644
--- a/src/components/TriagePanel.tsx
+++ b/src/components/TriagePanel.tsx
@@ -1,4 +1,5 @@
-import React, { useRef, useState } from 'react';
+import type { ToolLaunch } from '../types';
+import React, { useRef, useState, useEffect } from 'react';
import {
Stethoscope,
Play,
@@ -44,6 +45,7 @@ import {
*/
interface TriagePanelProps {
+ launch?: ToolLaunch;
onHistoryUpdate: () => void;
}
@@ -185,7 +187,7 @@ const FindingCard: React.FC<{ finding: Finding; rank: number }> = ({ finding, ra
);
};
-export const TriagePanel: React.FC = ({ onHistoryUpdate }) => {
+export const TriagePanel: React.FC = ({ onHistoryUpdate, launch }) => {
const [verdict, setVerdict] = useState(null);
const [progress, setProgress] = useState(null);
const [isRunning, setIsRunning] = useState(false);
@@ -238,6 +240,17 @@ export const TriagePanel: React.FC = ({ onHistoryUpdate }) =>
execute();
};
+ const launchedIdRef = useRef(null);
+ useEffect(() => {
+ if (launch === undefined || launchedIdRef.current === launch.id) return;
+ // Defer until mount effects settle so StrictMode cannot cancel the only run.
+ const timer = window.setTimeout(() => {
+ launchedIdRef.current = launch.id;
+ void handleRun();
+ }, 0);
+ return () => window.clearTimeout(timer);
+ });
+
const passed = verdict?.steps.filter((s) => s.status === 'pass').length ?? 0;
return (
diff --git a/src/components/WalkTest.tsx b/src/components/WalkTest.tsx
index d4a1293..597c228 100644
--- a/src/components/WalkTest.tsx
+++ b/src/components/WalkTest.tsx
@@ -1,4 +1,5 @@
-import React, { useMemo, useRef, useState } from 'react';
+import type { ToolLaunch } from '../types';
+import React, { useMemo, useRef, useState, useEffect } from 'react';
import {
ArrowUpDown,
Footprints,
@@ -75,6 +76,7 @@ import { StorageFullError, saveHistoryItem } from '../utils/storage';
*/
interface WalkTestProps {
+ launch?: ToolLaunch;
onHistoryUpdate: () => void;
}
@@ -286,11 +288,11 @@ const DestinationCard: React.FC = ({
);
};
-export const WalkTest: React.FC = ({ onHistoryUpdate }) => {
+export const WalkTest: React.FC = ({ onHistoryUpdate, launch }) => {
const [isWalking, setIsWalking] = useState(false);
const [samples, setSamples] = useState([]);
const [waypoints, setWaypoints] = useState([]);
- const [spotName, setSpotName] = useState('');
+ const [spotName, setSpotName] = useState(launch?.values.spot ?? '');
const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS);
const [sortKey, setSortKey] = useState('median');
const [result, setResult] = useState(null);
@@ -307,6 +309,11 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => {
const abortRef = useRef(null);
const startedAtRef = useRef(0);
+ useEffect(() => () => {
+ // A walk is continuous; leaving the tool must end its background probes.
+ abortRef.current?.abort();
+ }, []);
+
const targets = WALK_TARGETS;
const openSpot = (label: string) => {
@@ -354,6 +361,17 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => {
}
};
+ const launchedIdRef = useRef(null);
+ useEffect(() => {
+ if (launch === undefined || launchedIdRef.current === launch.id) return;
+ // Defer until mount effects settle so StrictMode cannot cancel the only run.
+ const timer = window.setTimeout(() => {
+ launchedIdRef.current = launch.id;
+ void start();
+ }, 0);
+ return () => window.clearTimeout(timer);
+ });
+
const stop = () => {
abortRef.current?.abort();
@@ -576,6 +594,22 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => {
+ {!isWalking && (
+
+
+ Starting spot (optional)
+
+ setSpotName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && void start()}
+ placeholder="e.g. living room"
+ className="w-full bg-slate-800 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-teal-500/60"
+ />
+
+ )}
+
{isWalking ? (
void;
}
@@ -15,7 +17,7 @@ const STUN_PRESETS = [
{ label: 'Mozilla STUN', url: 'stun:stun.services.mozilla.com' },
];
-export const WebRtcAnalyzer: React.FC = ({ onHistoryUpdate }) => {
+export const WebRtcAnalyzer: React.FC = ({ onHistoryUpdate, launch }) => {
const [selectedStun, setSelectedStun] = useState(STUN_PRESETS[0].url);
const [customStun, setCustomStun] = useState('');
const [isScanning, setIsScanning] = useState(false);
@@ -50,6 +52,17 @@ export const WebRtcAnalyzer: React.FC = ({ onHistoryUpdate
}
};
+ const launchedIdRef = useRef(null);
+ useEffect(() => {
+ if (launch === undefined || launchedIdRef.current === launch.id) return;
+ // Defer until mount effects settle so StrictMode cannot cancel the only run.
+ const timer = window.setTimeout(() => {
+ launchedIdRef.current = launch.id;
+ void handleRunScan();
+ }, 0);
+ return () => window.clearTimeout(timer);
+ });
+
return (
{/* Header Banner */}
@@ -65,24 +78,6 @@ export const WebRtcAnalyzer: React.FC = ({ onHistoryUpdate
Inspect real-time ICE candidate gathering, discover public server-reflexive (srflx) IPs, local host IPs, protocol bindings, and infer NAT firewall types.
-
-
- {isScanning ? (
- <>
-
- Gathering Candidates...
- >
- ) : (
- <>
-
- Inspect ICE Candidates
- >
- )}
-
{/* STUN Server Config */}
@@ -124,6 +119,24 @@ export const WebRtcAnalyzer: React.FC = ({ onHistoryUpdate
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2 text-xs text-slate-200 focus:outline-none focus:border-amber-500 font-mono"
/>
+
+
+ {isScanning ? (
+ <>
+
+ Gathering Candidates...
+ >
+ ) : (
+ <>
+
+ Inspect ICE Candidates
+ >
+ )}
+
{/* Discovered IP Cards */}
diff --git a/src/components/WebSocketTester.tsx b/src/components/WebSocketTester.tsx
index 59a5840..8008f19 100644
--- a/src/components/WebSocketTester.tsx
+++ b/src/components/WebSocketTester.tsx
@@ -1,10 +1,13 @@
-import React, { useState } from 'react';
+import type { ToolLaunch } from '../types';
+import React, { useState, useEffect, useRef } from 'react';
import { Terminal, Play, RefreshCw } from 'lucide-react';
import { WebSocketResult, HistoryItem } from '../types';
import { testWebSocket } from '../utils/network';
import { saveHistoryItem } from '../utils/storage';
+import { MetricValue, FailureNotice, displayMetric } from './MetricValue';
interface WebSocketTesterProps {
+ launch?: ToolLaunch;
onHistoryUpdate: () => void;
}
@@ -13,10 +16,11 @@ const WS_PRESETS = [
'wss://ws.postman-echo.com/raw',
];
-export const WebSocketTester: React.FC = ({ onHistoryUpdate }) => {
- const [wsUrl, setWsUrl] = useState('wss://echo.websocket.org');
+export const WebSocketTester: React.FC = ({ onHistoryUpdate, launch }) => {
+ const [wsUrl, setWsUrl] = useState(launch?.values.url ?? 'wss://echo.websocket.org');
const [isTesting, setIsTesting] = useState(false);
const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
const handleTestWs = async (overrideUrl?: string) => {
const url = overrideUrl || wsUrl;
@@ -24,6 +28,7 @@ export const WebSocketTester: React.FC = ({ onHistoryUpdat
setIsTesting(true);
setResult(null);
+ setError(null);
try {
const res = await testWebSocket(url);
@@ -36,7 +41,7 @@ export const WebSocketTester: React.FC = ({ onHistoryUpdat
timestamp: res.timestamp,
title: `WebSocket Test: ${res.url}`,
summary: res.status === 'connected'
- ? `Connected in ${res.handshakeTimeMs} ms | Avg Ping: ${res.avgPingMs} ms`
+ ? `Connected in ${displayMetric(res.handshakeTimeMs, 'ms')} | Avg Ping: ${displayMetric(res.avgPingMs, 'ms')}`
: `Connection Error / Closed`,
data: res,
};
@@ -45,11 +50,23 @@ export const WebSocketTester: React.FC = ({ onHistoryUpdat
onHistoryUpdate();
} catch (e) {
console.error('WebSocket test failed:', e);
+ setError(e instanceof Error ? `WebSocket test could not finish: ${e.message}` : 'WebSocket test could not finish.');
} finally {
setIsTesting(false);
}
};
+ const launchedIdRef = useRef(null);
+ useEffect(() => {
+ if (launch === undefined || launchedIdRef.current === launch.id) return;
+ // Defer until mount effects settle so StrictMode cannot cancel the only run.
+ const timer = window.setTimeout(() => {
+ launchedIdRef.current = launch.id;
+ void handleTestWs();
+ }, 0);
+ return () => window.clearTimeout(timer);
+ });
+
return (
{/* Header Banner */}
@@ -65,24 +82,6 @@ export const WebSocketTester: React.FC = ({ onHistoryUpdat
Test persistent WebSocket handshake connection setup times, message frame roundtrip latency, and echo packet reliability.
-
- handleTestWs()}
- disabled={isTesting}
- className="flex items-center space-x-2 bg-gradient-to-r from-teal-500 to-emerald-600 hover:from-teal-400 hover:to-emerald-500 text-white font-semibold px-6 py-3 rounded-xl text-sm transition-all shadow-lg shadow-teal-500/20 active:scale-95 disabled:opacity-50"
- >
- {isTesting ? (
- <>
-
- Connecting & Echoing...
- >
- ) : (
- <>
-
- Test WebSocket Connection
- >
- )}
-
{/* Input Form */}
@@ -116,8 +115,32 @@ export const WebSocketTester: React.FC = ({ onHistoryUpdat
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-slate-100 font-mono focus:outline-none focus:border-teal-500"
/>
+
+ handleTestWs()}
+ disabled={isTesting}
+ className="flex items-center space-x-2 bg-gradient-to-r from-teal-500 to-emerald-600 hover:from-teal-400 hover:to-emerald-500 text-white font-semibold px-6 py-3 rounded-xl text-sm transition-all shadow-lg shadow-teal-500/20 active:scale-95 disabled:opacity-50"
+ >
+ {isTesting ? (
+ <>
+
+ Connecting & Echoing...
+ >
+ ) : (
+ <>
+
+ Test WebSocket Connection
+ >
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
{/* Results View */}
{result && (
@@ -142,16 +165,18 @@ export const WebSocketTester: React.FC = ({ onHistoryUpdat
+
+
Handshake Setup Latency
-
{result.handshakeTimeMs} ms
+
Average Echo Ping RTT
- {result.avgPingMs > 0 ? `${result.avgPingMs} ms` : 'N/A'}
+
diff --git a/src/index.css b/src/index.css
index 2eb5cee..4ba6a4a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -95,7 +95,7 @@
border: 1px solid #334155 !important;
}
-.custom-tracert-marker {
+.custom-edge-marker {
background: transparent !important;
border: none !important;
}
diff --git a/src/types.ts b/src/types.ts
index 5752e75..3b3fb1b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,7 +5,6 @@ export type ToolTab =
| 'dualstack'
| 'captive'
| 'edgepath'
- | 'tracert'
| 'portscanner'
| 'geoip'
| 'speedtest'
@@ -20,6 +19,13 @@ export type ToolTab =
| 'history'
| 'export';
+/** A deliberate dashboard submission, consumed once by the destination tool. */
+export interface ToolLaunch {
+ id: number;
+ tab: ToolTab;
+ values: Record
;
+}
+
export interface NetworkConnectionInfo {
downlink?: number; // Mbps
effectiveType?: string; // '4g', '3g', '2g', 'slow-2g'
@@ -201,19 +207,20 @@ export interface WebSocketResult {
id: string;
timestamp: number;
url: string;
- handshakeTimeMs: number;
+ handshakeTimeMs: number | null;
pings: number[];
- avgPingMs: number;
+ avgPingMs: number | null;
status: 'connected' | 'error' | 'closed';
messagesSent: number;
messagesReceived: number;
+ failures?: MeasurementFailure[];
}
export interface PortStatus {
host: string;
port: number;
- status: 'open' | 'closed' | 'filtered';
- latencyMs: number;
+ status: 'open' | 'closed' | 'filtered' | 'not-tested';
+ latencyMs: number | null;
service?: string;
description?: string;
isWeb?: boolean;
@@ -236,40 +243,6 @@ export interface PortScanResult {
scanEngine?: 'server' | 'browser';
}
-export interface TracertHop {
- hop: number;
- ip: string;
- hostname: string;
- rtt1: number; // ms (-1 if timeout)
- rtt2: number;
- rtt3: number;
- avgRtt: number;
- status: 'success' | 'slow' | 'timeout';
- lat: number;
- lng: number;
- city: string;
- region: string;
- country: string;
- countryCode: string;
- isp: string;
- asn: string;
- nodeType: 'client' | 'gateway' | 'isp_pop' | 'transit' | 'backbone' | 'datacenter' | 'destination';
-}
-
-export interface TracertResult {
- id: string;
- timestamp: number;
- targetHost: string;
- targetIp: string;
- protocol: 'ICMP' | 'UDP' | 'TCP';
- maxHops: number;
- totalHops: number;
- totalDistanceKm: number;
- avgLatencyMs: number;
- totalTimeMs: number;
- hops: TracertHop[];
-}
-
export interface GeoIpResult {
id: string;
timestamp: number;
@@ -281,16 +254,16 @@ export interface GeoIpResult {
countryCode: string;
flag?: string;
postal?: string;
- lat: number;
- lng: number;
+ lat: number | null;
+ lng: number | null;
timezone?: string;
isp: string;
org?: string;
asn?: string;
callingCode?: string;
currency?: string;
- isProxy?: boolean;
- isVpn?: boolean;
+ isProxy?: boolean | null;
+ isVpn?: boolean | null;
}
// ---------------------------------------------------------------------------
@@ -381,9 +354,8 @@ export interface EdgeClientView {
}
/**
- * Evidence about HTTP/3 support. A network that blocks UDP/443 forces a fallback
- * to HTTP/2 even against origins that advertise h3, which is directly
- * observable and worth surfacing.
+ * Observed negotiated protocols. Lack of HTTP/3 does not reveal whether the
+ * browser attempted QUIC, or whether UDP/443 is blocked.
*/
export interface ProtocolEvidence {
/** Protocols negotiated across all probed origins, deduplicated. */
@@ -837,7 +809,6 @@ export interface HistoryItem {
| 'cidr'
| 'mac'
| 'portscanner'
- | 'tracert'
| 'geoip'
| 'edgepath'
| 'triage'
diff --git a/src/utils/edgePath.test.ts b/src/utils/edgePath.test.ts
index 08f05cd..37a4aea 100644
--- a/src/utils/edgePath.test.ts
+++ b/src/utils/edgePath.test.ts
@@ -252,15 +252,58 @@ describe('assessProtocols', () => {
expect(r.h3Count).toBe(1);
});
- it('infers blocked UDP when every h3-capable origin falls back to h2', () => {
+ it('reports observed HTTP/2 without identifying why HTTP/3 was absent', () => {
const r = assessProtocols([probe('h2'), probe('h2'), probe('h2')]);
expect(r.verdict).toBe('http3-absent-udp-possibly-blocked');
- expect(r.explanation).toMatch(/UDP\/443/);
+ expect(r.h2Count).toBe(3);
+ expect(r.explanation).toContain('Browser settings');
+ expect(r.explanation).toContain('do not establish the cause');
+ expect(r.explanation).not.toMatch(/attempted QUIC|fell back/);
});
- it('flags a total fallback to HTTP/1.1', () => {
+ it('reports one readable HTTP/2 result without implying all probe targets answered', () => {
+ const r = assessProtocols([probe('h2'), probe(null), probe(null)]);
+ expect(r.h2Count).toBe(1);
+ expect(r.explanation).toContain('1 readable response(s)');
+ expect(r.explanation).not.toContain('All');
+ });
+
+ it('describes mixed HTTP/2 and HTTP/1.x responses accurately', () => {
+ const r = assessProtocols([probe('h2'), probe('http/1.1')]);
+ expect(r.verdict).toBe('http3-absent-udp-possibly-blocked');
+ expect(r.explanation).toContain('HTTP/2: 1; HTTP/1.x: 1');
+ expect(r.negotiated).toEqual(['h2', 'http/1.1']);
+ expect(r.explanation).not.toMatch(/All .*HTTP\/2/);
+ });
+
+ it('leaves unknown protocol evidence indeterminate', () => {
+ const r = assessProtocols([probe('h2'), probe('unknown')]);
+ expect(r.verdict).toBeNull();
+ expect(r.h2Count).toBe(1);
+ expect(r.explanation).toContain('Unrecognised protocol(s): unknown');
+ expect(r.negotiated).toEqual(['h2', 'unknown']);
+ });
+
+ it('does not classify an unrecognised h3-like value as measured HTTP/3', () => {
+ expect(assessProtocols([probe('h3-unknown')]).verdict).toBeNull();
+ expect(assessProtocols([probe('h3-29')]).verdict).toBe('http3-working');
+ });
+
+ it('excludes failed requests even if they contain a protocol value', () => {
+ const failed = { ...probe('h3'), availability: 'request-failed' as const };
+ const r = assessProtocols([failed, probe('h2')]);
+ expect(r.h3Count).toBe(0);
+ expect(r.h2Count).toBe(1);
+ expect(r.negotiated).toEqual(['h2']);
+ expect(r.verdict).toBe('http3-absent-udp-possibly-blocked');
+ expect(assessProtocols([failed]).verdict).toBeNull();
+ });
+
+ it('reports observed HTTP/1.x without asserting a fallback', () => {
const r = assessProtocols([probe('http/1.1'), probe('http/1.1')]);
expect(r.verdict).toBe('legacy-http1');
+ expect(r.explanation).toContain('2 readable responses used HTTP/1.x');
+ expect(r.explanation).toContain('does not establish a fallback');
});
it('deduplicates the negotiated list', () => {
diff --git a/src/utils/edgePath.ts b/src/utils/edgePath.ts
index d6bc55f..7e778e7 100644
--- a/src/utils/edgePath.ts
+++ b/src/utils/edgePath.ts
@@ -11,7 +11,7 @@ import type {
} from '../types';
import { createId } from './network';
import { lookupIata } from '../data/iata';
-import { calculateGreatCircleDistanceKm } from './tracert';
+import { calculateGreatCircleDistanceKm } from './geo';
/**
* Edge Path Explorer.
@@ -23,8 +23,8 @@ import { calculateGreatCircleDistanceKm } from './tracert';
* 1. The DNS β TCP β TLS β TTFB breakdown of a real connection, from the
* Resource Timing API.
* 2. Which CDN edge answered, by IATA code, resolved to a real coordinate.
- * 3. The protocol negotiated (h3 / h2 / http/1.1), which reveals whether
- * UDP/443 is being blocked somewhere upstream.
+ * 3. The protocol negotiated (h3 / h2 / http/1.1). This reports HTTP/3
+ * when observed, but cannot establish why another protocol was used.
* 4. An upper bound on how far away a server can be, from round-trip time
* and the speed of light in fibre.
*
@@ -303,16 +303,19 @@ export async function fetchTargetPop(host: string, signal?: AbortSignal): Promis
}
/**
- * Turns observed protocols into a verdict about UDP/443.
+ * Summarises protocols observed on successful requests.
*
- * All four probe origins advertise HTTP/3. If every one of them negotiated
- * HTTP/2 instead, the browser tried QUIC and fell back β which is real evidence
- * that UDP/443 is blocked upstream, not a guess.
+ * Resource Timing exposes the negotiated protocol, not QUIC connection
+ * attempts, browser settings, or protocol discovery. Missing HTTP/3 therefore
+ * cannot identify a blocked UDP path. Failed requests supply no protocol evidence.
*/
export function assessProtocols(probes: EdgeProbeResult[]): ProtocolEvidence {
- const protocols = probes.map((p) => p.protocol).filter((p): p is string => Boolean(p));
+ const protocols = probes
+ .filter((p) => p.availability !== 'request-failed')
+ .map((p) => p.protocol)
+ .filter((p): p is string => Boolean(p));
- const h3Count = protocols.filter((p) => p.startsWith('h3')).length;
+ const h3Count = protocols.filter((p) => /^h3(?:-\d+)?$/.test(p)).length;
const h2Count = protocols.filter((p) => p === 'h2').length;
const http1Count = protocols.filter((p) => p.startsWith('http/1')).length;
const negotiated = Array.from(new Set(protocols));
@@ -338,8 +341,23 @@ export function assessProtocols(probes: EdgeProbeResult[]): ProtocolEvidence {
http1Count,
verdict: 'http3-working',
explanation:
- `HTTP/3 negotiated with ${h3Count} of ${protocols.length} origins, so QUIC over ` +
- 'UDP/443 is reaching the network. This usually means lower latency on lossy links.',
+ `HTTP/3 was observed on ${h3Count} of ${protocols.length} readable responses. ` +
+ 'Its QUIC transport succeeded for those requests.',
+ };
+ }
+
+ const observed = `HTTP/2: ${h2Count}; HTTP/1.x: ${http1Count}.`;
+ const unknown = protocols.filter((p) => p !== 'h2' && !p.startsWith('http/1'));
+ if (unknown.length > 0) {
+ return {
+ negotiated,
+ h3Count,
+ h2Count,
+ http1Count,
+ verdict: null,
+ explanation:
+ `${observed} Unrecognised protocol(s): ${Array.from(new Set(unknown)).join(', ')}. ` +
+ 'These responses do not establish HTTP/3 availability or a network fault.',
};
}
@@ -351,8 +369,9 @@ export function assessProtocols(probes: EdgeProbeResult[]): ProtocolEvidence {
http1Count,
verdict: 'legacy-http1',
explanation:
- 'Every connection fell back to HTTP/1.1. A proxy or TLS-inspecting middlebox is the ' +
- 'usual cause, and it costs both throughput and latency.',
+ `All ${protocols.length} readable responses used HTTP/1.x. ` +
+ 'Browser settings, server configuration, or a proxy may explain this. ' +
+ 'The negotiated protocol alone does not establish a fallback or its cause.',
};
}
@@ -363,10 +382,9 @@ export function assessProtocols(probes: EdgeProbeResult[]): ProtocolEvidence {
http1Count,
verdict: 'http3-absent-udp-possibly-blocked',
explanation:
- `All ${protocols.length} origins negotiated HTTP/2 despite advertising HTTP/3. The ` +
- 'browser attempted QUIC and fell back, which points to UDP/443 being blocked by a ' +
- 'firewall or middlebox on this network. Connections still work; they just lose the ' +
- 'faster path.',
+ `No HTTP/3 was observed on ${protocols.length} readable response(s). ${observed} ` +
+ 'Browser settings, protocol discovery, connection reuse, or blocked UDP could explain ' +
+ 'this. The negotiated protocols alone do not establish the cause.',
};
}
diff --git a/src/utils/export.ts b/src/utils/export.ts
index 49801bb..dc7b169 100644
--- a/src/utils/export.ts
+++ b/src/utils/export.ts
@@ -21,7 +21,6 @@ export const TEST_TYPES = [
{ id: 'dualstack', label: 'IPv4 / IPv6 Reachability', filename: 'dualstack_results.csv', icon: 'Network' },
{ id: 'dnsbench', label: 'DNS Resolver Benchmark', filename: 'dnsbench_results.csv', icon: 'Timer' },
{ id: 'captive', label: 'Portal & DNS Hijack Checks', filename: 'captive_results.csv', icon: 'ShieldQuestion' },
- { id: 'tracert', label: 'Traceroute (TRACERT)', filename: 'tracert_results.csv', icon: 'GitCommit' },
{ id: 'speedtest', label: 'Speed Test Results', filename: 'speedtest_results.csv', icon: 'Gauge' },
{ id: 'ping', label: 'Ping & Latency Tests', filename: 'ping_results.csv', icon: 'Radio' },
{ id: 'portscanner', label: 'Port Scanner Results', filename: 'portscanner_results.csv', icon: 'Radar' },
@@ -99,83 +98,6 @@ export function generateMasterSummaryCsv(items: HistoryItem[]): string {
return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
}
-// Generate Tracert CSV
-export function generateTracertCsv(items: HistoryItem[]): string {
- const tracertItems = items.filter((i) => i.type === 'tracert');
- const headers = [
- 'Test ID',
- 'Timestamp',
- 'Date',
- 'Target Host',
- 'Resolved IP',
- 'Protocol',
- 'Total Hops',
- 'Total Distance (km)',
- 'Avg Route Latency (ms)',
- 'Total Time (ms)',
- 'Hop #',
- 'Hop IP',
- 'Hop Hostname',
- 'Hop Avg RTT (ms)',
- 'Hop Status',
- 'Hop Location',
- 'Hop ISP / ASN',
- ];
-
- const rows: string[][] = [];
-
- tracertItems.forEach((item) => {
- const d = item.data || {};
- const dateStr = new Date(item.timestamp).toLocaleString();
-
- if (Array.isArray(d.hops) && d.hops.length > 0) {
- d.hops.forEach((h: any) => {
- rows.push([
- escapeCsv(item.id),
- escapeCsv(item.timestamp),
- escapeCsv(dateStr),
- escapeCsv(d.targetHost || item.title),
- escapeCsv(d.targetIp || ''),
- escapeCsv(d.protocol || 'ICMP'),
- escapeCsv(d.totalHops || d.hops.length),
- escapeCsv(d.totalDistanceKm || 0),
- escapeCsv(d.avgLatencyMs || 0),
- escapeCsv(d.totalTimeMs || 0),
- escapeCsv(h.hop),
- escapeCsv(h.ip),
- escapeCsv(h.hostname || ''),
- escapeCsv(h.avgRtt > 0 ? h.avgRtt : 'Timeout'),
- escapeCsv(h.status || ''),
- escapeCsv(`${h.city || ''}, ${h.country || ''}`.trim()),
- escapeCsv(`${h.isp || ''} (${h.asn || ''})`.trim()),
- ]);
- });
- } else {
- rows.push([
- escapeCsv(item.id),
- escapeCsv(item.timestamp),
- escapeCsv(dateStr),
- escapeCsv(item.title),
- escapeCsv(''),
- escapeCsv('ICMP'),
- escapeCsv(0),
- escapeCsv(0),
- escapeCsv(0),
- escapeCsv(0),
- escapeCsv('-'),
- escapeCsv('-'),
- escapeCsv('-'),
- escapeCsv('-'),
- escapeCsv('-'),
- escapeCsv('-'),
- escapeCsv('-'),
- ]);
- }
- });
-
- return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
-}
-
/**
* Speed test CSV.
*
@@ -991,8 +913,6 @@ export function generateGenericCsv(items: HistoryItem[], type: string): string {
// Get CSV content for a specific test type
export function getCsvForType(items: HistoryItem[], type: string): string {
switch (type) {
- case 'tracert':
- return generateTracertCsv(items);
case 'speedtest':
return generateSpeedtestCsv(items);
case 'ping':
@@ -1076,7 +996,7 @@ ${TEST_TYPES.map((t) => {
System Notes:
- CSV files can be imported into Microsoft Excel, Google Sheets, or Pandas.
-- Raw JSON contains complete nested diagnostic metrics including WebRTC ICE candidates, DNS TTLs, and traceroute hop latencies.
+- Raw JSON contains complete nested diagnostic metrics including WebRTC ICE candidates, DNS TTLs, and measured edge connection timings.
=====================================================
Generated by NetReady Network Suite
`;
diff --git a/src/utils/geo.ts b/src/utils/geo.ts
new file mode 100644
index 0000000..ff9c48e
--- /dev/null
+++ b/src/utils/geo.ts
@@ -0,0 +1,34 @@
+// Haversine formula to compute great-circle distance between coordinates in KM
+export function calculateGreatCircleDistanceKm(
+ lat1: number,
+ lon1: number,
+ lat2: number,
+ lon2: number
+): number {
+ if (lat1 === lat2 && lon1 === lon2) return 0;
+ const R = 6371; // Earth radius in kilometers
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
+ const dLon = ((lon2 - lon1) * Math.PI) / 180;
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos((lat1 * Math.PI) / 180) *
+ Math.cos((lat2 * Math.PI) / 180) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return Math.round(R * c);
+}
+
+/** RFC 1918 / loopback / link-local β addresses with no global location. */
+export function isPrivateOrLoopback(ip: string): boolean {
+ if (ip === 'localhost') return true;
+ const octets = ip.split('.').map((o) => parseInt(o, 10));
+ if (octets.length !== 4 || octets.some((o) => isNaN(o))) return false;
+ const [a, b] = octets as [number, number, number, number];
+ if (a === 10 || a === 127) return true;
+ if (a === 192 && b === 168) return true;
+ if (a === 172 && b >= 16 && b <= 31) return true; // full /12, not just 172.16.x
+ if (a === 169 && b === 254) return true; // link-local
+ return false;
+}
+
diff --git a/src/utils/network.test.ts b/src/utils/network.test.ts
index 3a66022..ebae927 100644
--- a/src/utils/network.test.ts
+++ b/src/utils/network.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
meanConsecutiveDelta,
createId,
@@ -8,8 +8,13 @@ import {
median,
sampleStdDev,
summariseSamples,
+ timeoutSignal,
+ runSpeedTest,
+ testWebSocket,
+ scanSinglePort,
+ scanPortList,
} from './network';
-import { isPrivateOrLoopback } from './tracert';
+import { isPrivateOrLoopback } from './geo';
import type { SpeedTestResult, PingResult } from '../types';
const speed = (over: Partial = {}): SpeedTestResult => ({
@@ -148,6 +153,269 @@ describe('parseTargetHosts', () => {
// pinned to an exact number rather than "something reasonable".
expect(parseTargetHosts('10.0.0.0/16')).toHaveLength(256);
});
+
+ it('rejects malformed octets rather than wrapping them into another network', () => {
+ for (const input of ['999.0.0.1/30', '192.168.1.256', '192.168.1.1-300']) {
+ expect(() => parseTargetHosts(input)).toThrow('Invalid IPv4 address');
+ }
+ });
+
+ it('rejects unsupported or fractional CIDR prefixes', () => {
+ for (const input of ['10.0.0.1/8', '10.0.0.1/30.5', '10.0.0.1/30junk']) {
+ expect(() => parseTargetHosts(input)).toThrow('CIDR prefixes');
+ }
+ });
+
+ it('preserves hostnames containing hyphens', () => {
+ expect(parseTargetHosts('my-dev-server.example')).toEqual(['my-dev-server.example']);
+ });
+});
+
+describe('timeoutSignal', () => {
+ it('propagates cancellation that occurred before the helper was called', () => {
+ const gate = timeoutSignal(10000, AbortSignal.abort());
+ expect(gate.signal.aborted).toBe(true);
+ gate.done();
+ });
+});
+
+describe('unmeasured scanner ports', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('reports browser-restricted ports as not tested without a latency', async () => {
+ const fetch = vi.fn();
+ vi.stubGlobal('fetch', fetch);
+ const result = await scanSinglePort('192.168.1.1', 22);
+ expect(result.status).toBe('not-tested');
+ expect(result.latencyMs).toBeNull();
+ expect(result.description).toContain('browser security policy');
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('does not classify requested ports that were skipped after discovery failed', async () => {
+ const fetch = vi.fn(async (_url: string) => {
+ const error = new Error('Probe timed out');
+ error.name = 'AbortError';
+ throw error;
+ });
+ vi.stubGlobal('fetch', fetch);
+ vi.stubGlobal('Image', class {
+ src = '';
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ });
+ const pending = scanPortList('192.168.1.1-2', [3306, 22]);
+ await vi.advanceTimersByTimeAsync(1000);
+ const result = await pending;
+ expect(result.ports).toHaveLength(4);
+ expect(result.ports.every((port) => port.status === 'not-tested' && port.latencyMs === null)).toBe(true);
+ expect(result.openPortsCount).toBe(0);
+ expect(result.closedPortsCount).toBe(0);
+ expect(result.filteredPortsCount).toBe(0);
+ expect(fetch.mock.calls).toHaveLength(6);
+ expect(fetch.mock.calls.every(([url]) => !String(url).includes(':3306/') && !String(url).includes(':22/'))).toBe(true);
+ });
+
+ it('keeps measured zero latency and excludes restricted ports from filtered totals', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(null)));
+ const result = await scanPortList('192.168.1.1', [80, 22]);
+ expect(result.ports.find((port) => port.port === 80)).toMatchObject({ status: 'open', latencyMs: 0 });
+ expect(result.ports.find((port) => port.port === 22)).toMatchObject({ status: 'not-tested', latencyMs: null });
+ expect(result.openPortsCount).toBe(1);
+ expect(result.filteredPortsCount).toBe(0);
+ });
+});
+
+describe('bufferbloat load evidence', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal('navigator', { onLine: true });
+ });
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('withholds the grade when latency works but every transfer is blocked', async () => {
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ if (url.includes('bytes=0')) return new Response(null);
+ throw new TypeError('Transfer blocked');
+ }));
+ const pending = runSpeedTest();
+ await vi.advanceTimersByTimeAsync(11000);
+ const result = await pending;
+ expect(result.ping).not.toBeNull();
+ expect(result.totalBytesDownloaded).toBe(0);
+ expect(result.downloadSpeed).toBeNull();
+ expect(result.loadedPing).toBeNull();
+ expect(result.bufferbloatScore).toBeNull();
+ expect(result.failures).toEqual(expect.arrayContaining([
+ expect.objectContaining({ metric: 'bufferbloatScore', reason: 'insufficient-samples' }),
+ ]));
+ });
+
+ it('grades only when actual download bytes overlap the latency sample', async () => {
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ if (url.includes('bytes=0')) {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ return new Response(null);
+ }
+ if (url.includes('__up')) throw new TypeError('Upload blocked');
+ return { body: { getReader: () => ({
+ read: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ return { done: false, value: new Uint8Array(1000) };
+ },
+ cancel: async () => {},
+ }) } };
+ }));
+ const pending = runSpeedTest();
+ await vi.advanceTimersByTimeAsync(11000);
+ const result = await pending;
+ expect(result.downloadSpeed).not.toBeNull();
+ expect(result.loadedPing).not.toBeNull();
+ expect(result.bufferbloatScore).not.toBeNull();
+ });
+
+ it('does not mistake an earlier completed transfer for concurrent load', async () => {
+ let downloads = 0;
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => {
+ if (url.includes('bytes=0')) return new Response(null);
+ if (url.includes('__down') && downloads++ === 0) {
+ return new Response(new Uint8Array(1000000));
+ }
+ throw new TypeError('Subsequent transfers blocked');
+ }));
+ const pending = runSpeedTest();
+ await vi.advanceTimersByTimeAsync(11000);
+ const result = await pending;
+ expect(result.totalBytesDownloaded).toBeGreaterThan(0);
+ expect(result.loadedPing).toBeNull();
+ expect(result.bufferbloatScore).toBeNull();
+ });
+});
+
+class MockWebSocket {
+ static OPEN = 1;
+ static CLOSED = 3;
+ static instance: MockWebSocket;
+ readyState = 0;
+ onopen: (() => void) | null = null;
+ onmessage: ((event: { data: string }) => void) | null = null;
+ onerror: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+ sent: string[] = [];
+ constructor() { MockWebSocket.instance = this; }
+ send(payload: string) { this.sent.push(payload); }
+ close() { this.readyState = MockWebSocket.CLOSED; this.onclose?.(); }
+ open() { this.readyState = MockWebSocket.OPEN; this.onopen?.(); }
+ message(data: string) { this.onmessage?.({ data }); }
+}
+
+describe('WebSocket measurements', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.stubGlobal('WebSocket', MockWebSocket);
+ });
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('reports missing timings and reasons on handshake failure', async () => {
+ const pending = testWebSocket('wss://test.example');
+ MockWebSocket.instance.onerror?.();
+ const result = await pending;
+ expect(result.status).toBe('error');
+ expect(result.handshakeTimeMs).toBeNull();
+ expect(result.avgPingMs).toBeNull();
+ expect(result.failures?.map((failure) => failure.metric)).toEqual(['handshakeTimeMs', 'avgPingMs']);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('settles constructor failures and cancels the timeout', async () => {
+ vi.stubGlobal('WebSocket', class {
+ constructor() { throw new Error('Invalid WebSocket URL'); }
+ });
+ const result = await testWebSocket('invalid');
+ expect(result.status).toBe('error');
+ expect(result.handshakeTimeMs).toBeNull();
+ expect(result.avgPingMs).toBeNull();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('settles when the server closes without an echo', async () => {
+ const pending = testWebSocket('wss://test.example');
+ MockWebSocket.instance.open();
+ MockWebSocket.instance.close();
+ const result = await pending;
+ expect(result.status).toBe('closed');
+ expect(result.handshakeTimeMs).not.toBeNull();
+ expect(result.avgPingMs).toBeNull();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('ignores unsolicited messages and times out without inventing RTT', async () => {
+ const pending = testWebSocket('wss://test.example');
+ const socket = MockWebSocket.instance;
+ socket.open();
+ socket.message('Welcome, this is not an echo');
+ await vi.advanceTimersByTimeAsync(4000);
+ const result = await pending;
+ expect(result.status).toBe('error');
+ expect(result.messagesReceived).toBe(0);
+ expect(result.avgPingMs).toBeNull();
+ expect(result.failures).toEqual(expect.arrayContaining([
+ expect.objectContaining({ metric: 'avgPingMs', reason: 'timeout' }),
+ ]));
+ });
+
+ it('accepts exact echoes once and rejects duplicates and stale replies', async () => {
+ const pending = testWebSocket('wss://test.example');
+ const socket = MockWebSocket.instance;
+ socket.open();
+ for (let index = 0; index < 3; index++) {
+ const payload = socket.sent[index];
+ if (index > 0) socket.message(socket.sent[index - 1]);
+ await vi.advanceTimersByTimeAsync(10);
+ socket.message(payload);
+ socket.message(payload);
+ await vi.advanceTimersByTimeAsync(150);
+ }
+ const result = await pending;
+ expect(result.status).toBe('connected');
+ expect(result.messagesSent).toBe(3);
+ expect(result.messagesReceived).toBe(3);
+ expect(result.pings).toEqual([10, 10, 10]);
+ expect(result.avgPingMs).toBe(10);
+ expect(result.failures).toBeUndefined();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('preserves a measured partial RTT while reporting an incomplete timeout', async () => {
+ const pending = testWebSocket('wss://test.example');
+ const socket = MockWebSocket.instance;
+ socket.open();
+ await vi.advanceTimersByTimeAsync(10);
+ socket.message(socket.sent[0]);
+ await vi.advanceTimersByTimeAsync(4000);
+ const result = await pending;
+ expect(result.status).toBe('error');
+ expect(result.pings).toEqual([10]);
+ expect(result.avgPingMs).toBe(10);
+ expect(result.messagesReceived).toBe(1);
+ expect(result.failures).toEqual([
+ expect.objectContaining({ metric: 'echoReplies', reason: 'timeout' }),
+ ]);
+ expect(vi.getTimerCount()).toBe(0);
+ });
});
describe('isPrivateOrLoopback', () => {
diff --git a/src/utils/network.ts b/src/utils/network.ts
index 9600b8f..f087c0b 100644
--- a/src/utils/network.ts
+++ b/src/utils/network.ts
@@ -58,6 +58,7 @@ export function timeoutSignal(
const timer = setTimeout(() => controller.abort(), ms);
const onAbort = () => controller.abort();
external?.addEventListener('abort', onAbort);
+ if (external?.aborted) controller.abort();
return {
signal: controller.signal,
done: () => {
@@ -492,10 +493,19 @@ export async function runSpeedTest(
let loadedPingTimer: ReturnType | undefined;
const loadedPingDone = new Promise((resolve) => {
loadedPingTimer = setTimeout(async () => {
+ const bytesBeforeProbe = totalBytesDownloaded;
+ if (bytesBeforeProbe === 0 || signal?.aborted) {
+ resolve();
+ return;
+ }
const start = performance.now();
try {
await fetch(`${LATENCY_URL}&_loaded=${Date.now()}`, { cache: 'no-store', signal });
- loadedPing = Math.round(performance.now() - start);
+ // Starting workers is not evidence of load. Require actual download
+ // bytes to arrive during the latency probe before calling it loaded.
+ if (totalBytesDownloaded > bytesBeforeProbe) {
+ loadedPing = Math.round(performance.now() - start);
+ }
} catch {
/* leave null β an unmeasured loaded ping must not become ping + 14 */
}
@@ -595,7 +605,7 @@ export async function runSpeedTest(
failures.push({
metric: 'bufferbloatScore',
reason: 'insufficient-samples',
- detail: 'Bufferbloat needs both an idle and a under-load latency sample.',
+ detail: 'Bufferbloat needs idle latency and a latency sample with observed concurrent download traffic.',
});
}
@@ -863,20 +873,44 @@ export async function testWebSocket(
return new Promise((resolve) => {
let ws: WebSocket | null = null;
- let handshakeTimeMs = 0;
+ let handshakeTimeMs: number | null = null;
let messagesSent = 0;
let messagesReceived = 0;
-
- const cleanup = (status: 'connected' | 'error' | 'closed') => {
+ let settled = false;
+ let nextEchoTimer: ReturnType | undefined;
+ let pendingPayload: string | null = null;
+ let pendingStart = 0;
+
+ const cleanup = (
+ status: WebSocketResult['status'],
+ reason: MeasurementFailure['reason'] = 'api-unreachable',
+ ) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ clearTimeout(nextEchoTimer);
if (ws) {
- ws.close();
+ ws.onopen = null;
+ ws.onmessage = null;
+ ws.onerror = null;
+ ws.onclose = null;
+ try { ws.close(); } catch { /* already closed */ }
ws = null;
}
const avgPingMs =
- pings.length > 0 ? Math.round(pings.reduce((a, b) => a + b, 0) / pings.length) : 0;
+ pings.length > 0 ? Math.round(pings.reduce((a, b) => a + b, 0) / pings.length) : null;
+ const failures: MeasurementFailure[] = [];
+ if (handshakeTimeMs === null) {
+ failures.push({ metric: 'handshakeTimeMs', reason, detail: 'The WebSocket handshake did not complete.' });
+ }
+ if (avgPingMs === null) {
+ failures.push({ metric: 'avgPingMs', reason, detail: 'No matching echo reply was received, so round-trip latency was not measured.' });
+ } else if (messagesReceived < 3) {
+ failures.push({ metric: 'echoReplies', reason, detail: 'The connection ended before all three matching echo replies arrived.' });
+ }
resolve({
- id: 'ws_' + Date.now(),
+ id: createId('ws'),
timestamp: Date.now(),
url: wsUrl,
handshakeTimeMs,
@@ -885,8 +919,22 @@ export async function testWebSocket(
status,
messagesSent,
messagesReceived,
+ failures: failures.length ? failures : undefined,
});
};
+ const timeout = setTimeout(() => cleanup('error', 'timeout'), 4000);
+
+ const sendEcho = () => {
+ if (!ws || ws.readyState !== WebSocket.OPEN || settled) return;
+ pendingStart = performance.now();
+ pendingPayload = JSON.stringify({ ping: messagesSent + 1, time: pendingStart });
+ try {
+ ws.send(pendingPayload);
+ messagesSent++;
+ } catch {
+ cleanup('error');
+ }
+ };
try {
ws = new WebSocket(wsUrl);
@@ -894,42 +942,20 @@ export async function testWebSocket(
ws.onopen = () => {
handshakeTimeMs = Math.round(performance.now() - startTime);
- // Send 3 ping echo packets
- const sendEcho = (seq: number) => {
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
- const msgStart = performance.now();
- const payload = JSON.stringify({ ping: seq, time: msgStart });
- ws.send(payload);
- messagesSent++;
-
- const onMsg = () => {
- const rtt = Math.round(performance.now() - msgStart);
- pings.push(rtt);
- messagesReceived++;
- ws?.removeEventListener('message', onMsg);
-
- if (seq < 3) {
- setTimeout(() => sendEcho(seq + 1), 150);
- } else {
- cleanup('connected');
- }
- };
-
- ws.addEventListener('message', onMsg);
- };
-
- sendEcho(1);
+ sendEcho();
};
-
- ws.onerror = () => {
- cleanup('error');
+ ws.onmessage = (event) => {
+ // Banners, broadcasts, stale replies and duplicate echoes do not time
+ // the request we sent. Only an exact echo is an RTT measurement.
+ if (settled || pendingPayload === null || event.data !== pendingPayload) return;
+ pendingPayload = null;
+ pings.push(Math.round(performance.now() - pendingStart));
+ messagesReceived++;
+ if (messagesReceived === 3) cleanup('connected');
+ else nextEchoTimer = setTimeout(sendEcho, 150);
};
-
- setTimeout(() => {
- if (ws && ws.readyState !== WebSocket.CLOSED) {
- cleanup(pings.length > 0 ? 'connected' : 'error');
- }
- }, 4000);
+ ws.onerror = () => cleanup('error');
+ ws.onclose = () => cleanup('closed');
} catch {
cleanup('error');
}
@@ -1131,6 +1157,12 @@ export function describeTargetExpansion(input: string): string | null {
export function parseTargetHosts(input: string): string[] {
const raw = input.trim();
if (!raw) return ['127.0.0.1'];
+ const ipv4Pattern = /^\d+\.\d+\.\d+\.\d+$/;
+ const validateIpv4 = (ip: string) => {
+ if (!ipv4Pattern.test(ip) || ip.split('.').some((octet) => Number(octet) > 255)) {
+ throw new Error(`Invalid IPv4 address: ${ip}`);
+ }
+ };
// Handle comma separated values
if (raw.includes(',')) {
@@ -1144,7 +1176,13 @@ export function parseTargetHosts(input: string): string[] {
// Handle CIDR subnets e.g. 192.168.1.0/24 or 10.0.0.0/28
if (raw.includes('/')) {
const [ipPart, prefixStr] = raw.split('/');
- const prefix = parseInt(prefixStr, 10);
+ const prefix = Number(prefixStr);
+ if (ipv4Pattern.test(ipPart.trim())) {
+ validateIpv4(ipPart.trim());
+ if (!Number.isInteger(prefix) || prefix < 16 || prefix > 32) {
+ throw new Error('Port scanner CIDR prefixes must be whole numbers from 16 through 32.');
+ }
+ }
if (!isNaN(prefix) && prefix >= 16 && prefix <= 32) {
const ipToNum = (ip: string) =>
ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
@@ -1152,6 +1190,7 @@ export function parseTargetHosts(input: string): string[] {
[(num >>> 24) & 255, (num >>> 16) & 255, (num >>> 8) & 255, num & 255].join('.');
const cleanIp = ipPart.trim();
+ validateIpv4(cleanIp);
const maskLong = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
const ipLong = ipToNum(cleanIp);
const networkLong = (ipLong & maskLong) >>> 0;
@@ -1174,7 +1213,7 @@ export function parseTargetHosts(input: string): string[] {
}
// Handle Range e.g. 192.168.1.1-192.168.1.20 or 192.168.1.1-20
- if (raw.includes('-') && !raw.includes('nmap.org')) {
+ if (/^\d+\.\d+\.\d+\.\d+-/.test(raw)) {
const parts = raw.split('-');
const startStr = parts[0].trim();
let endStr = parts[1].trim();
@@ -1185,6 +1224,8 @@ export function parseTargetHosts(input: string): string[] {
endStr = `${octets[0]}.${octets[1]}.${octets[2]}.${endStr}`;
}
}
+ validateIpv4(startStr);
+ validateIpv4(endStr);
const ipToNum = (ip: string) =>
ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
@@ -1204,6 +1245,7 @@ export function parseTargetHosts(input: string): string[] {
}
const clean = raw.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
+ if (ipv4Pattern.test(clean)) validateIpv4(clean);
return [clean];
}
@@ -1231,13 +1273,13 @@ export async function scanSinglePort(
service.toLowerCase().includes('http');
const protocol: 'http' | 'https' = port === 443 || port === 8443 ? 'https' : 'http';
- // If port is restricted by browser policy, classify as filtered (browser restricted)
+ // Browser policy prevented the probe; this says nothing about the port.
if (RESTRICTED_PORTS.includes(port)) {
return {
host: cleanHost,
port,
- status: 'filtered',
- latencyMs: 0,
+ status: 'not-tested',
+ latencyMs: null,
service: `${service} (Browser Restricted)`,
description: `${description} - Probing restricted by browser security policy.`,
isWeb,
@@ -1461,19 +1503,19 @@ export async function scanPortList(
if (item.alive) {
aliveHosts.push(item.host);
} else {
- // Pre-populate offline hosts' ports as filtered to save time
+ // Discovery failed on other ports. These requested ports were never
+ // probed, so neither a port status nor latency has been measured.
for (const p of ports) {
resultsMap.push({
host: item.host,
port: p,
- status: 'filtered',
- latencyMs: 0,
+ status: 'not-tested',
+ latencyMs: null,
service: COMMON_PORTS.find((cp) => cp.port === p)?.service || `Port ${p}`,
- description: 'Host unreachable during discovery pre-check',
+ description: 'Not probed because host discovery received no conclusive response.',
isWeb: [80, 443, 3000, 5000, 8000, 8080, 8443, 8001, 8081, 8888, 9000].includes(p),
protocol: p === 443 || p === 8443 ? 'https' : 'http',
});
- filteredCount++;
}
}
}
@@ -1482,8 +1524,8 @@ export async function scanPortList(
const dummyResult: PortStatus = {
host: chunk[chunk.length - 1],
port: 0,
- status: 'filtered',
- latencyMs: 0,
+ status: 'not-tested',
+ latencyMs: null,
service: 'Host Discovery',
description: 'Ping & Multi-probe Host Discovery',
isWeb: false,
@@ -1519,7 +1561,7 @@ export async function scanPortList(
if (res.status === 'open') openCount++;
else if (res.status === 'closed') closedCount++;
- else filteredCount++;
+ else if (res.status === 'filtered') filteredCount++;
processedCount++;
if (onProgress) {
diff --git a/src/utils/ports.test.ts b/src/utils/ports.test.ts
new file mode 100644
index 0000000..7792461
--- /dev/null
+++ b/src/utils/ports.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { MAX_SCAN_PORTS, parsePortSelection } from './ports';
+
+describe('parsePortSelection', () => {
+ it('parses full ranges, deduplicates overlaps, and sorts the requested ports', () => {
+ expect(parsePortSelection('443, 80, 3000 - 3002, 3001')).toEqual([80, 443, 3000, 3001, 3002]);
+ expect(parsePortSelection('1-150')).toHaveLength(150);
+ });
+
+ it('accepts valid boundary ports and the full aggregate limit', () => {
+ expect(parsePortSelection('1,65535')).toEqual([1, 65535]);
+ expect(parsePortSelection(`1-${MAX_SCAN_PORTS},1-${MAX_SCAN_PORTS}`)).toHaveLength(MAX_SCAN_PORTS);
+ });
+
+ it.each(['', ' ', 'abc', '80garbage', '80.5', '0', '65536', '443-80', '80-90-100', '-80', '80,', ',80', '80,,443', '80,garbage', '1-9007199254740992'])('rejects malformed selection %j rather than scanning defaults or a subset', input => {
+ expect(() => parsePortSelection(input)).toThrow();
+ });
+
+ it('rejects oversized ranges and aggregate selections before probing', () => {
+ expect(() => parsePortSelection('1-65535')).toThrow('at most 1000');
+ expect(() => parsePortSelection('1-600,1000-1600')).toThrow('at most 1000');
+ });
+});
diff --git a/src/utils/ports.ts b/src/utils/ports.ts
new file mode 100644
index 0000000..f0b1fc2
--- /dev/null
+++ b/src/utils/ports.ts
@@ -0,0 +1,30 @@
+/** Browser scans are bounded, but oversized selections must never be silently truncated. */
+export const MAX_SCAN_PORTS = 1000;
+
+/** Parse a complete selection or reject it; no partial scans or substitute defaults. */
+export function parsePortSelection(input: string): number[] {
+ if (!input.trim()) throw new Error('Enter at least one port or port range.');
+
+ const ports = new Set();
+ for (const raw of input.split(',')) {
+ const part = raw.trim();
+ const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part);
+ if (!match) throw new Error(`Invalid port selection "${part}". Use ports or ranges separated by commas.`);
+
+ const start = Number(match[1]);
+ const end = match[2] === undefined ? start : Number(match[2]);
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end > 65535 || start > end) {
+ throw new Error(`Invalid port selection "${part}". Ports must be 1β65535 and ranges must be in ascending order.`);
+ }
+ if (end - start + 1 > MAX_SCAN_PORTS) {
+ throw new Error(`Select at most ${MAX_SCAN_PORTS} distinct ports per scan. This range is too large; no ports were selected.`);
+ }
+ for (let port = start; port <= end; port++) {
+ ports.add(port);
+ if (ports.size > MAX_SCAN_PORTS) {
+ throw new Error(`Select at most ${MAX_SCAN_PORTS} distinct ports per scan. This selection is too large; no ports were selected.`);
+ }
+ }
+ }
+ return [...ports].sort((a, b) => a - b);
+}
diff --git a/src/utils/storage.test.ts b/src/utils/storage.test.ts
new file mode 100644
index 0000000..e0b658b
--- /dev/null
+++ b/src/utils/storage.test.ts
@@ -0,0 +1,32 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { deleteHistoryItem, getHistory, saveHistoryItem } from './storage';
+import type { HistoryItem } from '../types';
+
+const measured: HistoryItem = { id: 'measured', type: 'dns', timestamp: 2, title: 'DNS lookup', summary: 'Measured result', data: {} };
+const legacy = { id: 'legacy', type: 'tracert', timestamp: 1, title: 'Simulated route', summary: 'Generated hops', data: { hops: [] } };
+
+beforeEach(() => {
+ const entries = new Map();
+ vi.stubGlobal('localStorage', {
+ getItem: (key: string) => entries.get(key) ?? null,
+ setItem: (key: string, value: string) => entries.set(key, value),
+ removeItem: (key: string) => entries.delete(key),
+ clear: () => entries.clear(),
+ });
+});
+afterEach(() => vi.unstubAllGlobals());
+describe('legacy simulated history', () => {
+ it('excludes simulations from diagnostic history and export sources', () => {
+ localStorage.setItem('netready_history_v1', JSON.stringify([legacy, measured]));
+ expect(getHistory()).toEqual([measured]);
+ });
+ it('preserves old stored records when new measurements are saved or deleted', () => {
+ localStorage.setItem('netready_history_v1', JSON.stringify([legacy]));
+ saveHistoryItem(measured);
+ expect(getHistory()).toEqual([measured]);
+ expect(JSON.parse(localStorage.getItem('netready_history_v1')!)).toContainEqual(legacy);
+ deleteHistoryItem(measured.id);
+ expect(getHistory()).toEqual([]);
+ expect(JSON.parse(localStorage.getItem('netready_history_v1')!)).toEqual([legacy]);
+ });
+});
diff --git a/src/utils/storage.ts b/src/utils/storage.ts
index 59d1c6a..52145be 100644
--- a/src/utils/storage.ts
+++ b/src/utils/storage.ts
@@ -20,7 +20,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
theme: 'dark',
};
-export const getHistory = (): HistoryItem[] => {
+const readStoredHistory = (): HistoryItem[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY_HISTORY);
if (!raw) return [];
@@ -32,6 +32,10 @@ export const getHistory = (): HistoryItem[] => {
}
};
+/** Keep legacy simulations out of diagnostics and exports without erasing stored records. */
+export const getHistory = (): HistoryItem[] =>
+ readStoredHistory().filter(item => String(item.type) !== 'tracert');
+
/** Raised when the browser refuses a write because the origin quota is full.
* Callers must surface this: silently returning the previous history made a
* dropped record look like a successful save. */
@@ -50,7 +54,7 @@ const isQuotaError = (e: unknown): boolean =>
(e.name === 'QuotaExceededError' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED');
export const saveHistoryItem = (item: HistoryItem): HistoryItem[] => {
- const current = getHistory();
+ const current = readStoredHistory();
const maxItems = getSettings().maxHistoryItems;
const updated = [item, ...current].slice(0, maxItems);
@@ -84,7 +88,7 @@ export const saveHistoryItem = (item: HistoryItem): HistoryItem[] => {
export const deleteHistoryItem = (id: string): HistoryItem[] => {
try {
- const current = getHistory();
+ const current = readStoredHistory();
const updated = current.filter((item) => item.id !== id);
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated));
return updated;
diff --git a/src/utils/tracert.ts b/src/utils/tracert.ts
deleted file mode 100644
index 258f472..0000000
--- a/src/utils/tracert.ts
+++ /dev/null
@@ -1,441 +0,0 @@
-import { TracertHop, TracertResult } from '../types';
-import { queryDnsOverHttps } from './network';
-
-export interface TracertPreset {
- name: string;
- target: string;
- description: string;
- region: string;
- flag: string;
-}
-
-export const TRACERT_PRESETS: TracertPreset[] = [
- { name: 'Cloudflare (1.1.1.1)', target: '1.1.1.1', description: 'Global Edge Anycast DNS Network', region: 'Global Anycast', flag: 'π' },
- { name: 'Google DNS (8.8.8.8)', target: '8.8.8.8', description: 'Google Public Primary Resolver', region: 'US / Global', flag: 'πΊπΈ' },
- { name: 'GitHub HQ (github.com)', target: 'github.com', description: 'Microsoft Azure & Fastly Edge CDN', region: 'US East (Virginia)', flag: 'πΊπΈ' },
- { name: 'AWS US-East', target: 'dynamodb.us-east-1.amazonaws.com', description: 'Amazon Data Center Gateway', region: 'North Virginia, USA', flag: 'πΊπΈ' },
- { name: 'BBC London (bbc.co.uk)', target: 'bbc.co.uk', description: 'UK Public Broadcaster Infrastructure', region: 'London, United Kingdom', flag: 'π¬π§' },
- { name: 'CERN Geneva (home.cern)', target: 'home.cern', description: 'European Organization for Nuclear Research', region: 'Geneva, Switzerland', flag: 'π¨π' },
- { name: 'University of Tokyo (u-tokyo.ac.jp)', target: 'u-tokyo.ac.jp', description: 'SINET Academic Backbone Japan', region: 'Tokyo, Japan', flag: 'π―π΅' },
- { name: 'Telstra Sydney (telstra.com.au)', target: 'telstra.com.au', description: 'Major Australian Telecom Transit', region: 'Sydney, Australia', flag: 'π¦πΊ' },
- { name: 'MercadoLibre Brazil', target: 'mercadolibre.com.br', description: 'South American E-Commerce Datacenter', region: 'SΓ£o Paulo, Brazil', flag: 'π§π·' },
- { name: 'SANREN Network (sanren.ac.za)', target: 'sanren.ac.za', description: 'South African Research Network', region: 'Cape Town, South Africa', flag: 'πΏπ¦' },
-];
-
-// Haversine formula to compute great-circle distance between coordinates in KM
-export function calculateGreatCircleDistanceKm(
- lat1: number,
- lon1: number,
- lat2: number,
- lon2: number
-): number {
- if (lat1 === lat2 && lon1 === lon2) return 0;
- const R = 6371; // Earth radius in kilometers
- const dLat = ((lat2 - lat1) * Math.PI) / 180;
- const dLon = ((lon2 - lon1) * Math.PI) / 180;
- const a =
- Math.sin(dLat / 2) * Math.sin(dLat / 2) +
- Math.cos((lat1 * Math.PI) / 180) *
- Math.cos((lat2 * Math.PI) / 180) *
- Math.sin(dLon / 2) *
- Math.sin(dLon / 2);
- const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
- return Math.round(R * c);
-}
-
-/**
- * Raised when no GeoIP provider could resolve a location.
- *
- * Callers must handle absence. The alternative β which is what this module used
- * to do β is to invent a plausible city and plot it on a map.
- */
-export class GeoLookupUnavailableError extends Error {
- constructor(public readonly target: string) {
- super(
- `No GeoIP provider could resolve a location for ${target}. ` +
- 'The lookup services may be rate-limited, blocked, or unreachable.',
- );
- this.name = 'GeoLookupUnavailableError';
- }
-}
-
-/** RFC 1918 / loopback / link-local β addresses with no global location. */
-export function isPrivateOrLoopback(ip: string): boolean {
- if (ip === 'localhost') return true;
- const octets = ip.split('.').map((o) => parseInt(o, 10));
- if (octets.length !== 4 || octets.some((o) => isNaN(o))) return false;
- const [a, b] = octets as [number, number, number, number];
- if (a === 10 || a === 127) return true;
- if (a === 192 && b === 168) return true;
- if (a === 172 && b >= 16 && b <= 31) return true; // full /12, not just 172.16.x
- if (a === 169 && b === 254) return true; // link-local
- return false;
-}
-
-// Interface for GeoIP responses
-export interface GeoIpInfo {
- ip: string;
- city: string;
- region: string;
- country: string;
- countryCode: string;
- lat: number;
- lng: number;
- isp: string;
- asn: string;
-}
-
-// Real GeoIP lookup with robust fallback endpoints
-export async function fetchGeoIpData(ip: string): Promise {
- // Private and loopback addresses are not globally routable and have no
- // geolocation at all. This used to return San Francisco's coordinates for
- // every one of them, which put a user's own LAN gateway on the map in
- // California. Throwing keeps them off the map entirely, which is correct: a
- // private address is unlocatable, not located somewhere specific.
- if (isPrivateOrLoopback(ip)) {
- throw new GeoLookupUnavailableError(ip);
- }
-
- try {
- const res = await fetch(`https://freeipapi.com/api/json/${ip}`, { cache: 'no-store' });
- if (res.ok) {
- const data = await res.json();
- if (data.latitude && data.longitude) {
- return {
- ip,
- city: data.cityName || 'Unknown',
- region: data.regionName || 'Unknown',
- country: data.countryName || 'Unknown',
- countryCode: data.countryCode || '',
- lat: parseFloat(data.latitude),
- lng: parseFloat(data.longitude),
- isp: data.ipVersion ? `IPv${data.ipVersion}` : 'Unknown',
- asn: data.asn ? `AS${data.asn}` : 'Unknown',
- };
- }
- }
- } catch {
- // try secondary fallback
- }
-
- try {
- const res2 = await fetch(`https://ipapi.co/${ip}/json/`, { cache: 'no-store' });
- if (res2.ok) {
- const data2 = await res2.json();
- if (data2.latitude && data2.longitude) {
- return {
- ip,
- city: data2.city || 'Unknown',
- region: data2.region || 'Unknown',
- country: data2.country_name || 'Unknown',
- countryCode: data2.country_code || '',
- lat: parseFloat(data2.latitude),
- lng: parseFloat(data2.longitude),
- isp: data2.org || data2.asn || 'Unknown',
- asn: data2.asn || 'Unknown',
- };
- }
- }
- } catch {
- // fallback default
- }
-
- // Both providers failed (unreachable, rate-limited, or blocked). There is no
- // location to report. This used to return hardcoded Washington DC
- // coordinates labelled "Tier-1 Telecom Transit / AS15169", which were then
- // plotted on the map as though they had been looked up.
- throw new GeoLookupUnavailableError(ip);
-}
-
-// Get user's current client IP & Geo location
-export async function getClientGeoLocation(): Promise {
- try {
- const res = await fetch('https://freeipapi.com/api/json/', { cache: 'no-store' });
- if (res.ok) {
- const data = await res.json();
- if (data.latitude && data.longitude) {
- return {
- ip: data.ipAddress || 'Unknown',
- city: data.cityName || 'Unknown',
- region: data.regionName || 'Unknown',
- country: data.countryName || 'Unknown',
- countryCode: data.countryCode || '',
- lat: parseFloat(data.latitude),
- lng: parseFloat(data.longitude),
- isp: 'Client ISP Connection',
- asn: 'AS-CLIENT',
- };
- }
- }
- } catch {
- // secondary fallback
- }
-
- // Previously returned a hardcoded San Francisco / 192.168.1.100 / "AS7018
- // AT&T" identity for every user whose lookup failed.
- throw new GeoLookupUnavailableError('client');
-}
-
-// Intermediate Tier-1 transit backbones for realistic route interpolation
-const TRANSIT_BACKBONES = [
- { name: 'be2841.ccr41.ord01.atlas.cogentco.com', isp: 'Cogent Communications', asn: 'AS174', city: 'Chicago', region: 'Illinois', country: 'United States', code: 'US', lat: 41.8781, lng: -87.6298 },
- { name: 'ae-12-312.east-dc.level3.net', isp: 'Lumen / Level3 Communications', asn: 'AS3356', city: 'Ashburn', region: 'Virginia', country: 'United States', code: 'US', lat: 39.0438, lng: -77.4874 },
- { name: 'chi-b2-link.ip.twelve.net', isp: 'Telia / Arelion Carrier', asn: 'AS1299', city: 'New York', region: 'New York', country: 'United States', code: 'US', lat: 40.7128, lng: -74.0060 },
- { name: 'lhr-b1-get.ntt.net', isp: 'NTT Communications Global', asn: 'AS2914', city: 'London', region: 'England', country: 'United Kingdom', code: 'GB', lat: 51.5074, lng: -0.1278 },
- { name: 'fra-b4-link.de-cix.fra.de', isp: 'DE-CIX Frankfurt Exchange', asn: 'AS6695', city: 'Frankfurt', region: 'Hesse', country: 'Germany', code: 'DE', lat: 50.1109, lng: 8.6821 },
- { name: 'tyo-b2-sinet.ad.jp', isp: 'SINET Academic Exchange', asn: 'AS2907', city: 'Tokyo', region: 'Kanto', country: 'Japan', code: 'JP', lat: 35.6762, lng: 139.6503 },
- { name: 'syd-b1-ix.telstra.net', isp: 'Telstra Global IXP', asn: 'AS1221', city: 'Sydney', region: 'New South Wales', country: 'Australia', code: 'AU', lat: -33.8688, lng: 151.2093 },
-];
-
-// Main Traceroute Execution Function
-export async function executeTraceroute(
- target: string,
- protocol: 'ICMP' | 'UDP' | 'TCP' = 'ICMP',
- maxHops: number = 20,
- onHopDiscovered?: (hop: TracertHop, currentHops: TracertHop[]) => void
-): Promise {
- const startTime = performance.now();
- const cleanTarget = target.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
-
- // 1. Resolve Target IP via DoH
- let targetIp = cleanTarget;
- let targetGeo: GeoIpInfo | null = null;
-
- // Check if cleanTarget is an IP address
- const isIpAddress = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(cleanTarget);
-
- if (!isIpAddress) {
- try {
- const dnsRes = await queryDnsOverHttps(cleanTarget, 'A', 'cloudflare');
- if (dnsRes.records.length > 0) {
- targetIp = dnsRes.records[0].data;
- }
- } catch {
- // Keep targetIp as cleanTarget
- }
- }
-
- // Fetch Target real GeoIP location
- targetGeo = await fetchGeoIpData(targetIp);
-
- // 2. Fetch Client Geo location
- const clientGeo = await getClientGeoLocation();
-
- // 3. Build Hop Sequence
- const hops: TracertHop[] = [];
-
- // Hop 1: Local LAN Gateway
- const hop1: TracertHop = {
- hop: 1,
- ip: '192.168.1.1',
- hostname: 'gateway.local',
- rtt1: Math.round(1 + Math.random() * 2),
- rtt2: Math.round(1 + Math.random() * 2),
- rtt3: Math.round(1 + Math.random() * 2),
- avgRtt: 2,
- status: 'success',
- lat: clientGeo.lat,
- lng: clientGeo.lng,
- city: clientGeo.city,
- region: clientGeo.region,
- country: clientGeo.country,
- countryCode: clientGeo.countryCode,
- isp: 'Local LAN Gateway Router',
- asn: 'AS-LOCAL',
- nodeType: 'gateway',
- };
- hops.push(hop1);
- if (onHopDiscovered) onHopDiscovered(hop1, [...hops]);
-
- await new Promise((res) => setTimeout(res, 200));
-
- // Hop 2: ISP Access Node / Aggregator
- const hop2: TracertHop = {
- hop: 2,
- ip: `10.${Math.floor(Math.random() * 200)}.${Math.floor(Math.random() * 255)}.1`,
- hostname: `node-pop-01.${clientGeo.countryCode.toLowerCase()}.isp-edge.net`,
- rtt1: Math.round(6 + Math.random() * 4),
- rtt2: Math.round(7 + Math.random() * 4),
- rtt3: Math.round(6 + Math.random() * 3),
- avgRtt: 7,
- status: 'success',
- lat: clientGeo.lat + (Math.random() - 0.5) * 0.15,
- lng: clientGeo.lng + (Math.random() - 0.5) * 0.15,
- city: clientGeo.city,
- region: clientGeo.region,
- country: clientGeo.country,
- countryCode: clientGeo.countryCode,
- // 'AS7018' is AT&T. Presenting it as a fallback attributed a real,
- // identifiable network operator to a hop that was never observed.
- isp: clientGeo.isp || 'Unknown',
- asn: clientGeo.asn || 'Unknown',
- nodeType: 'isp_pop',
- };
- hops.push(hop2);
- if (onHopDiscovered) onHopDiscovered(hop2, [...hops]);
-
- await new Promise((res) => setTimeout(res, 220));
-
- // Determine total intermediate hops based on distance between client & target
- const initialDistance = calculateGreatCircleDistanceKm(
- clientGeo.lat,
- clientGeo.lng,
- targetGeo.lat,
- targetGeo.lng
- );
-
- // Estimate total hops (minimum 5, max 18)
- const estimatedHopsCount = Math.min(
- maxHops,
- Math.max(6, Math.min(18, Math.floor(initialDistance / 800) + 6))
- );
-
- let currentLat = clientGeo.lat;
- let currentLng = clientGeo.lng;
- let currentRtt = 8;
-
- // Intermediate Hops Loop
- for (let h = 3; h < estimatedHopsCount; h++) {
- const fraction = h / estimatedHopsCount;
-
- // Simulate occasional timeout/packet loss (e.g., 8% chance on hop 6 or 11)
- const isTimeout = Math.random() < 0.06;
-
- if (isTimeout) {
- const timeoutHop: TracertHop = {
- hop: h,
- ip: '*',
- hostname: 'Request timed out (* * *)',
- rtt1: -1,
- rtt2: -1,
- rtt3: -1,
- avgRtt: -1,
- status: 'timeout',
- lat: currentLat + (targetGeo.lat - clientGeo.lat) * fraction,
- lng: currentLng + (targetGeo.lng - clientGeo.lng) * fraction,
- city: 'Transit Switch',
- region: 'Filtered Hop',
- country: 'Global Backbone',
- countryCode: 'XX',
- isp: 'ICMP Rate-Limited Filter',
- asn: 'AS-FILTER',
- nodeType: 'transit',
- };
- hops.push(timeoutHop);
- if (onHopDiscovered) onHopDiscovered(timeoutHop, [...hops]);
- await new Promise((res) => setTimeout(res, 180));
- continue;
- }
-
- // Interpolate coordinates towards target with geographic jitter
- currentLat = clientGeo.lat + (targetGeo.lat - clientGeo.lat) * fraction + (Math.random() - 0.5) * 1.5;
- currentLng = clientGeo.lng + (targetGeo.lng - clientGeo.lng) * fraction + (Math.random() - 0.5) * 1.5;
-
- // Increment latency proportional to distance & hop step
- currentRtt += Math.round(8 + Math.random() * 15 + (initialDistance / estimatedHopsCount) * 0.05);
-
- const r1 = Math.round(currentRtt + (Math.random() - 0.5) * 4);
- const r2 = Math.round(currentRtt + (Math.random() - 0.5) * 4);
- const r3 = Math.round(currentRtt + (Math.random() - 0.5) * 4);
- const avgR = Math.round((r1 + r2 + r3) / 3);
-
- // Pick realistic transit backbone details
- const backboneTemplate = TRANSIT_BACKBONES[(h - 3) % TRANSIT_BACKBONES.length];
-
- const transitHop: TracertHop = {
- hop: h,
- ip: `162.219.${Math.floor(Math.random() * 200) + 10}.${Math.floor(Math.random() * 254) + 1}`,
- hostname: `${backboneTemplate.name.split('.')[0]}-hop${h}.${backboneTemplate.country.toLowerCase()}.net`,
- rtt1: r1,
- rtt2: r2,
- rtt3: r3,
- avgRtt: avgR,
- status: avgR > 120 ? 'slow' : 'success',
- lat: currentLat,
- lng: currentLng,
- city: backboneTemplate.city,
- region: backboneTemplate.region,
- country: backboneTemplate.country,
- countryCode: backboneTemplate.code,
- isp: backboneTemplate.isp,
- asn: backboneTemplate.asn,
- nodeType: h === estimatedHopsCount - 1 ? 'backbone' : 'transit',
- };
-
- hops.push(transitHop);
- if (onHopDiscovered) onHopDiscovered(transitHop, [...hops]);
- await new Promise((res) => setTimeout(res, 200));
- }
-
- // Final Hop: Destination Endpoint
- const finalRttStart = performance.now();
- let measuredFinalPing = currentRtt + 12;
-
- // Try real latency measurement to destination
- try {
- await fetch(`https://${cleanTarget}/`, { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
- const elapsed = Math.round(performance.now() - finalRttStart);
- if (elapsed > 0) measuredFinalPing = elapsed;
- } catch {
- // fallback
- }
-
- const fr1 = Math.round(measuredFinalPing + (Math.random() - 0.5) * 3);
- const fr2 = Math.round(measuredFinalPing + (Math.random() - 0.5) * 3);
- const fr3 = Math.round(measuredFinalPing + (Math.random() - 0.5) * 3);
- const finalAvg = Math.round((fr1 + fr2 + fr3) / 3);
-
- const finalHop: TracertHop = {
- hop: estimatedHopsCount,
- ip: targetIp,
- hostname: cleanTarget,
- rtt1: fr1,
- rtt2: fr2,
- rtt3: fr3,
- avgRtt: finalAvg,
- status: 'success',
- lat: targetGeo.lat,
- lng: targetGeo.lng,
- city: targetGeo.city,
- region: targetGeo.region,
- country: targetGeo.country,
- countryCode: targetGeo.countryCode,
- isp: targetGeo.isp,
- asn: targetGeo.asn,
- nodeType: 'destination',
- };
-
- hops.push(finalHop);
- if (onHopDiscovered) onHopDiscovered(finalHop, [...hops]);
-
- // Calculate metrics
- let totalDistanceKm = 0;
- for (let i = 0; i < hops.length - 1; i++) {
- const hA = hops[i];
- const hB = hops[i + 1];
- if (hA.lat && hA.lng && hB.lat && hB.lng) {
- totalDistanceKm += calculateGreatCircleDistanceKm(hA.lat, hA.lng, hB.lat, hB.lng);
- }
- }
-
- const validHops = hops.filter((h) => h.avgRtt > 0);
- const avgLatencyMs = validHops.length > 0
- ? Math.round(validHops.reduce((sum, h) => sum + h.avgRtt, 0) / validHops.length)
- : 0;
-
- const totalTimeMs = Math.round(performance.now() - startTime);
-
- return {
- id: 'tracert_' + Date.now(),
- timestamp: Date.now(),
- targetHost: cleanTarget,
- targetIp,
- protocol,
- maxHops,
- totalHops: hops.length,
- totalDistanceKm,
- avgLatencyMs,
- totalTimeMs,
- hops,
- };
-}