diff --git a/README.md b/README.md index 758d58a..72dcd21 100644 --- a/README.md +++ b/README.md @@ -65,17 +65,30 @@ Geolocation, ISP, ASN and proxy/VPN signals for an IP or domain, via third-party ### 11. 📊 Live Traffic Monitor Real-time throughput and latency from the browser's own Performance Timeline. -### 12. 🗺️ Route Model *(simulated — read this)* +### 12. 🧭 Edge Path Explorer +Everything a browser can genuinely observe about the path to a host: + +- **Connection phase breakdown** — real DNS → TCP → TLS → time-to-first-byte → transfer timings + from the Performance Timeline. Cross-origin phases require a `Timing-Allow-Origin` header, and + handshake phases only exist on a connection's *first* request; both conditions are detected and + reported rather than shown as zeros. +- **Which CDN edge answered**, by IATA code, resolved against a bundled airport table to a real + coordinate. An unknown code is flagged, not guessed. +- **HTTP/3 negotiation** — if every h3-capable origin falls back to HTTP/2, that is direct evidence + UDP/443 is blocked upstream by a firewall or middlebox. +- **Latency horizon** — light travels ~200 km/ms in fibre, so a round trip puts a hard ceiling on + how far away a server can be. Drawn as a constraint circle: the endpoint is somewhere inside it. + This is a proof, not an estimate — queuing delay only loosens the bound. + +### 13. 🗺️ Route Model *(simulated — read this)* Resolves a target, looks up its real location, and draws a plausible great-circle path to it. **The intermediate hops are generated, 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 are grounded in a real DNS resolution and a real geolocation lookup; everything between them is illustrative. Exports mark -these records as simulated. A replacement built on things the browser genuinely can observe — real -DNS/TCP/TLS/TTFB phase timings, the CDN edge you're actually routed to, and HTTP/3 negotiation — is -the next major piece of work. +these records as simulated. Use the Edge Path Explorer above for measurements you can rely on. -### 13. 💾 History & Export +### 14. 💾 History & Export Results persist in `localStorage`. Search, filter, inspect raw JSON, and export per-tool CSVs, a master summary, or a bundled ZIP with a manifest. @@ -112,6 +125,7 @@ without touching it, so the following go directly from your browser to third par | Provider | Receives | |---|---| | `speed.cloudflare.com` | Your IP, plus tens of MB of transfer, during a speed test | +| `cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `unpkg.com` | Your IP, as Edge Path Explorer probe targets (a few KB each) | | `cloudflare-dns.com`, `dns.google` | Every domain you resolve, over encrypted DoH | | `ipwho.is`, `ipapi.co`, `freeipapi.com` | Your public IP on opening the GeoIP tool, and every IP or domain you look up | | `1.1.1.1`, `dns.quad9.net`, `doh.opendns.com`, `en.wikipedia.org` | Your IP, as latency probe targets | diff --git a/src/App.tsx b/src/App.tsx index 2af074c..e88c8cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { getNetworkConnectionInfo } from './utils/network'; import { getHistory, getLocalStorageSizeBytes } from './utils/storage'; import { Navbar } from './components/Navbar'; import { Dashboard } from './components/Dashboard'; +import { EdgePathExplorer } from './components/EdgePathExplorer'; import { TracertVisualizer } from './components/TracertVisualizer'; import { PortScanner } from './components/PortScanner'; import { GeoIpLookup } from './components/GeoIpLookup'; @@ -79,6 +80,10 @@ export default function App() { /> )} + {activeTab === 'edgepath' && ( + + )} + {activeTab === 'tracert' && ( )} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 7b2fa2f..00f4e97 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -359,25 +359,52 @@ export const Dashboard: React.FC = ({
{/* Traceroute TRACERT Hop Map */}
setActiveTab('tracert')} + onClick={() => setActiveTab('edgepath')} className="group bg-gradient-to-br from-cyan-950/40 via-slate-900 to-slate-900 border border-cyan-500/30 hover:border-cyan-400 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-xl hover:shadow-cyan-500/10 hover:-translate-y-0.5" >
- +

- Traceroute Hop Map + Edge Path Explorer

- + New

- Trace network hops, intermediate transit nodes, GeoIP locations, and follow every hop live on an interactive map. + Real DNS, TCP, TLS and first-byte timings, the CDN edge that answered, and whether + HTTP/3 is reaching your network.

- Launch Tracert Map + Explore edge path + +
+
+ + {/* Route model (simulated) */} +
setActiveTab('tracert')} + className="group bg-slate-900 border border-slate-800 hover:border-amber-500/40 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5" + > +
+ +
+
+

+ Route Model +

+ + Simulated + +
+

+ An illustrative great-circle path to a target. Browsers cannot traceroute, so the + intermediate hops are modelled rather than measured. +

+
+ Open route model
diff --git a/src/components/EdgeMap.tsx b/src/components/EdgeMap.tsx new file mode 100644 index 0000000..a56d83f --- /dev/null +++ b/src/components/EdgeMap.tsx @@ -0,0 +1,243 @@ +import React, { useEffect, useRef } from 'react'; +import L from 'leaflet'; + +/** + * Map of real, measured network geography. + * + * Deliberately narrower than the route map it sits beside: it draws only points + * whose coordinates came from somewhere verifiable — a CDN edge identified by + * IATA code, or a client location reported by the edge itself — plus distance + * constraints derived from round-trip time. + * + * There are no interpolated waypoints, because there is nothing to interpolate + * between: a browser cannot see the routers in the middle. + */ + +export interface MapNode { + id: string; + lat: number; + lng: number; + kind: 'client' | 'edge-pop' | 'target-pop'; + label: string; + detail: string[]; +} + +export interface MapLink { + fromId: string; + toId: string; + label?: string; +} + +/** + * A distance constraint, not a location. + * + * Radius is the furthest the client can possibly be from this point given the + * measured round trip. The client is somewhere inside the circle; the circle + * does not claim to know where. + */ +export interface MapRing { + centerLat: number; + centerLng: number; + radiusKm: number; + label: string; +} + +interface EdgeMapProps { + nodes: MapNode[]; + links: MapLink[]; + rings: MapRing[]; + heightClass?: string; +} + +const NODE_COLOUR: Record = { + client: '#10b981', // emerald + 'edge-pop': '#06b6d4', // cyan + 'target-pop': '#f43f5e', // rose +}; + +const NODE_GLYPH: Record = { + client: 'â—Ž', + 'edge-pop': 'â–˛', + 'target-pop': 'â—†', +}; + +/** Element factory that applies text via textContent, never innerHTML. */ +function el(tag: string, cssText: string, text?: string): HTMLElement { + const node = document.createElement(tag); + node.style.cssText = cssText; + if (text !== undefined) node.textContent = text; + return node; +} + +function buildBadge(node: MapNode): HTMLElement { + const colour = NODE_COLOUR[node.kind]; + return el( + 'div', + `width:28px;height:28px;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:13px;font-family:monospace;box-shadow:0 0 14px ${colour};`, + NODE_GLYPH[node.kind], + ); +} + +/** + * Popup content, assembled as DOM nodes. + * + * Labels here include hostnames the user typed and strings returned by remote + * services, so none of it may be interpolated into markup. + */ +function buildPopup(node: MapNode): HTMLElement { + const colour = NODE_COLOUR[node.kind]; + 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;`, node.label), + ); + for (const line of node.detail) { + wrap.appendChild(el('div', 'color:#94a3b8;font-size:11px;line-height:1.5;', line)); + } + return wrap; +} + +export const EdgeMap: React.FC = ({ + nodes, + links, + rings, + heightClass = 'h-[420px]', +}) => { + const containerRef = useRef(null); + const mapRef = useRef(null); + const layerRef = useRef(null); + + // Map instance is created once and torn down on unmount. + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + const map = L.map(containerRef.current, { + center: [20, 0], + zoom: 2, + minZoom: 1, + maxZoom: 12, + zoomControl: false, + worldCopyJump: true, + }); + + 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); + layerRef.current = L.layerGroup().addTo(map); + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; + layerRef.current = null; + }; + }, []); + + // Redraw contents. Depends only on the data, so unrelated parent renders do + // not tear down and rebuild every layer. + useEffect(() => { + const map = mapRef.current; + const layer = layerRef.current; + if (!map || !layer) return; + + layer.clearLayers(); + + const byId = new Map(nodes.map((n) => [n.id, n])); + const bounds: [number, number][] = []; + + // Rings first so markers draw above them. + for (const ring of rings) { + L.circle([ring.centerLat, ring.centerLng], { + radius: ring.radiusKm * 1000, + color: '#06b6d4', + weight: 1, + opacity: 0.5, + fillColor: '#06b6d4', + fillOpacity: 0.05, + dashArray: '4 6', + }) + .bindTooltip(ring.label, { direction: 'top', className: 'edge-ring-tooltip' }) + .addTo(layer); + } + + for (const link of links) { + const from = byId.get(link.fromId); + const to = byId.get(link.toId); + if (!from || !to) continue; + L.polyline( + [ + [from.lat, from.lng], + [to.lat, to.lng], + ], + { color: '#06b6d4', weight: 2, opacity: 0.7, dashArray: '6 6' }, + ).addTo(layer); + } + + for (const node of nodes) { + bounds.push([node.lat, node.lng]); + L.marker([node.lat, node.lng], { + icon: L.divIcon({ + className: 'custom-tracert-marker', + html: buildBadge(node), + iconSize: [28, 28], + iconAnchor: [14, 14], + }), + title: node.label, + }) + .bindPopup(buildPopup(node), { className: 'tracert-dark-popup' }) + .addTo(layer); + } + + if (bounds.length === 1) { + map.setView(bounds[0], 5, { animate: true }); + } else if (bounds.length > 1) { + map.fitBounds(L.latLngBounds(bounds).pad(0.3), { animate: true }); + } + }, [nodes, links, rings]); + + return ( +
+
+ + {nodes.length === 0 && ( +
+

+ No mapped locations yet. Run an exploration to place your edge on the map. +

+
+ )} + +
+ {( + [ + ['client', 'You'], + ['edge-pop', 'CDN edge'], + ['target-pop', 'Target edge'], + ] as const + ).map(([kind, label]) => ( + + + ))} +
+
+ ); +}; diff --git a/src/components/EdgePathExplorer.tsx b/src/components/EdgePathExplorer.tsx new file mode 100644 index 0000000..a4c6733 --- /dev/null +++ b/src/components/EdgePathExplorer.tsx @@ -0,0 +1,514 @@ +import React, { useMemo, useState } from 'react'; +import { + Compass, + Play, + RefreshCw, + Server, + Radio, + Info, + ShieldCheck, + AlertTriangle, + CheckCircle2, +} from 'lucide-react'; +import type { EdgePathResult, EdgeProbeResult, HistoryItem, PhaseTimings } from '../types'; +import { exploreEdgePath } from '../utils/edgePath'; +import { saveHistoryItem } from '../utils/storage'; +import { ResponsibleNetworkingModal, isResponsibleNetworkingAccepted } from './ResponsibleNetworkingModal'; +import { FailureNotice, displayMetric } from './MetricValue'; +import { EdgeMap, type MapNode, type MapLink, type MapRing } from './EdgeMap'; + +interface EdgePathExplorerProps { + onHistoryUpdate: () => void; +} + +const PRESETS = ['cloudflare.com', 'discord.com', 'shopify.com', 'medium.com']; + +/** Colour and label for each phase of the connection. */ +const PHASE_SPEC = [ + { key: 'dnsMs', label: 'DNS', colour: 'bg-violet-500', hint: 'Resolving the hostname' }, + { key: 'tcpMs', label: 'TCP', colour: 'bg-cyan-500', hint: 'Opening the socket' }, + { key: 'tlsMs', label: 'TLS', colour: 'bg-emerald-500', hint: 'Negotiating encryption' }, + { key: 'ttfbMs', label: 'TTFB', colour: 'bg-amber-500', hint: 'Waiting for the first byte' }, + { key: 'transferMs', label: 'Transfer', colour: 'bg-slate-500', hint: 'Receiving the body' }, +] as const satisfies readonly { key: keyof PhaseTimings; label: string; colour: string; hint: string }[]; + +const AVAILABILITY_COPY: Record = { + available: '', + 'timing-allow-origin-missing': + 'This origin does not send Timing-Allow-Origin, so the browser zeroes its DNS, TCP and TLS timings. They are unavailable, not zero.', + 'connection-reused': + 'Answered over a connection that was already open, so there was no handshake to measure. Reload to force a fresh one.', + 'request-failed': 'The request did not complete.', +}; + +/** Stacked bar of the connection phases, proportional to their real durations. */ +const Waterfall: React.FC<{ probe: EdgeProbeResult }> = ({ probe }) => { + const segments = PHASE_SPEC.map((spec) => ({ + ...spec, + value: probe.phases[spec.key], + })).filter((s): s is (typeof PHASE_SPEC)[number] & { value: number } => s.value !== null && s.value > 0); + + const total = segments.reduce((sum, s) => sum + s.value, 0); + + if (segments.length === 0) { + return ( +
+ {AVAILABILITY_COPY[probe.availability] || 'No phase data.'} +
+ ); + } + + return ( +
+
+ {segments.map((s) => ( +
+ ))} +
+
+ {segments.map((s) => ( + + + ))} +
+
+ ); +}; + +export const EdgePathExplorer: React.FC = ({ onHistoryUpdate }) => { + const [targetHost, setTargetHost] = useState('cloudflare.com'); + const [isRunning, setIsRunning] = useState(false); + const [stage, setStage] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [showResponsibleModal, setShowResponsibleModal] = useState(false); + + const execute = async () => { + setIsRunning(true); + setError(null); + setResult(null); + try { + const res = await exploreEdgePath(targetHost, setStage); + setResult(res); + + const item: HistoryItem = { + id: res.id, + type: 'edgepath', + timestamp: res.timestamp, + title: `Edge path: ${res.targetHost ?? 'this connection'}${ + res.referencePop ? ` via ${res.referencePop.colo}` : '' + }`, + summary: [ + res.referencePop ? `Edge: ${res.referencePop.colo}` : 'Edge: unknown', + res.client?.asOrganization ? `ASN: ${res.client.asOrganization}` : null, + res.protocolEvidence.negotiated.length + ? `Protocol: ${res.protocolEvidence.negotiated.join(', ')}` + : null, + res.clientToPopKm !== null ? `${res.clientToPopKm} km to edge` : null, + ] + .filter(Boolean) + .join(' | '), + data: res, + }; + saveHistoryItem(item); + onHistoryUpdate(); + } catch (e) { + console.error('Edge path exploration failed:', e); + setError( + e instanceof Error ? `Exploration failed: ${e.message}` : 'Exploration failed.', + ); + } finally { + setIsRunning(false); + setStage(''); + } + }; + + const handleStart = () => { + if (!isResponsibleNetworkingAccepted()) { + setShowResponsibleModal(true); + return; + } + execute(); + }; + + // Only points with genuine coordinates reach the map. + const { nodes, links, rings } = useMemo(() => { + const n: MapNode[] = []; + const l: MapLink[] = []; + const r: MapRing[] = []; + if (!result) return { nodes: n, links: l, rings: r }; + + if (result.client?.lat != null && result.client?.lng != null) { + n.push({ + id: 'client', + lat: result.client.lat, + lng: result.client.lng, + kind: 'client', + label: 'Your connection', + detail: [ + result.client.ip ? `IP: ${result.client.ip}` : 'IP: unknown', + result.client.asOrganization + ? `Network: ${result.client.asOrganization}` + : 'Network: unknown', + result.client.asn ? `ASN: AS${result.client.asn}` : '', + [result.client.city, result.client.country].filter(Boolean).join(', '), + 'Location as reported by the edge that served you.', + ].filter(Boolean), + }); + } + + if (result.referencePop?.lat != null && result.referencePop?.lng != null) { + n.push({ + id: 'edge', + lat: result.referencePop.lat, + lng: result.referencePop.lng, + kind: 'edge-pop', + label: `Edge ${result.referencePop.colo}`, + detail: [ + [result.referencePop.city, result.referencePop.country].filter(Boolean).join(', '), + result.referencePop.httpProtocol + ? `Protocol: ${result.referencePop.httpProtocol}` + : '', + 'The CDN point of presence that answered your request.', + ].filter(Boolean), + }); + if (n.some((x) => x.id === 'client')) { + l.push({ fromId: 'client', toId: 'edge' }); + } + + // Constraint ring: the tightest round trip bounds how far the edge is. + const best = result.probes + .map((p) => p.maxDistanceKm) + .filter((d): d is number => d !== null); + if (best.length > 0) { + const radius = Math.min(...best); + r.push({ + centerLat: result.referencePop.lat, + centerLng: result.referencePop.lng, + radiusKm: radius, + label: `Within ${radius.toLocaleString()} km of this edge (speed-of-light limit)`, + }); + } + } + + if (result.targetPop?.lat != null && result.targetPop?.lng != null) { + n.push({ + id: 'target', + lat: result.targetPop.lat, + lng: result.targetPop.lng, + kind: 'target-pop', + label: `${result.targetHost ?? 'Target'} → ${result.targetPop.colo}`, + detail: [ + [result.targetPop.city, result.targetPop.country].filter(Boolean).join(', '), + result.targetPop.httpProtocol ? `Protocol: ${result.targetPop.httpProtocol}` : '', + 'The edge this host reports serving your traffic from.', + ].filter(Boolean), + }); + if (n.some((x) => x.id === 'client')) { + l.push({ fromId: 'client', toId: 'target' }); + } + } + + return { nodes: n, links: l, rings: r }; + }, [result]); + + const verdictStyle = (() => { + switch (result?.protocolEvidence.verdict) { + case 'http3-working': + return { icon: CheckCircle2, cls: 'text-emerald-300 bg-emerald-500/10 border-emerald-500/30' }; + case 'http3-absent-udp-possibly-blocked': + return { icon: AlertTriangle, cls: 'text-amber-300 bg-amber-500/10 border-amber-500/30' }; + case 'legacy-http1': + return { icon: AlertTriangle, cls: 'text-rose-300 bg-rose-500/10 border-rose-500/30' }; + default: + return { icon: Info, cls: 'text-slate-400 bg-slate-800/50 border-slate-700/50' }; + } + })(); + const VerdictIcon = verdictStyle.icon; + + return ( +
+ {/* Header */} +
+
+
+ +

Edge Path Explorer

+ + Measured + +
+

+ Everything a browser can genuinely observe about the path to a host: the real + DNS → TCP → TLS → first-byte breakdown, which CDN edge answered, the protocol that was + negotiated, and how far away a server can possibly be. +

+
+ + +
+ + {/* Why there are no intermediate hops. */} +
+ +

+ Why there are no router-by-router hops.{' '} + A traceroute works by sending packets with a deliberately small IP TTL and reading the + errors that come back. A web page can do neither — there are no raw sockets and no TTL + control in the browser. So rather than invent the middle of the path, this tool measures + the endpoints precisely: the phases of a real connection, the edge that terminated it, and + a distance bound that physics guarantees. +

+
+ + {isRunning && stage && ( +

+ {stage}… +

+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Target */} +
+ + setTargetHost(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isRunning && handleStart()} + placeholder="e.g. cloudflare.com" + className="w-full bg-slate-950 border border-white/10 rounded-xl px-4 py-3 text-sm text-slate-100 font-mono focus:outline-none focus:border-cyan-500 shadow-inner" + /> +
+ Presets: + {PRESETS.map((p) => ( + + ))} +
+

+ Only Cloudflare-fronted hosts expose their edge location to a browser. For anything else + the field is reported as unavailable rather than guessed — the rest of the exploration + still runs. +

+
+ + {result && ( + <> + + + {/* Summary tiles */} +
+ {[ + { + label: 'Serving edge', + value: result.referencePop?.colo ?? '—', + sub: result.referencePop + ? [result.referencePop.city, result.referencePop.country].filter(Boolean).join(', ') || + 'Location not in the bundled table' + : 'Not determined', + icon: Server, + }, + { + label: 'Distance to edge', + value: result.clientToPopKm !== null ? `${result.clientToPopKm.toLocaleString()}` : '—', + sub: result.clientToPopKm !== null ? 'km, great-circle' : 'Needs both locations', + icon: Compass, + }, + { + label: 'Your network', + value: result.client?.asn ? `AS${result.client.asn}` : '—', + sub: result.client?.asOrganization ?? 'Not determined', + icon: Radio, + }, + { + label: 'Protocol', + value: result.protocolEvidence.negotiated[0] ?? '—', + sub: result.protocolEvidence.negotiated.length > 1 + ? `also ${result.protocolEvidence.negotiated.slice(1).join(', ')}` + : 'Across probed origins', + icon: ShieldCheck, + }, + ].map((tile) => ( +
+
+ + {tile.label} +
+
{tile.value}
+
{tile.sub}
+
+ ))} +
+ + {/* The answer to what the user actually asked. Without this the target + host's edge was only reachable by opening a map popup. */} + {result.targetHost && ( +
+

+ {result.targetHost} +

+ {result.targetPop ? ( +
+ Your traffic enters at + + {result.targetPop.colo} + + + {[result.targetPop.city, result.targetPop.country].filter(Boolean).join(', ') || + 'an edge not present in the bundled location table'} + + {result.targetPop.httpProtocol && ( + + {result.targetPop.httpProtocol} + + )} +
+ ) : ( +

+ This host did not report an edge location. Only Cloudflare-fronted sites expose{' '} + /cdn-cgi/trace to a browser, so this is the + normal result for most of the web — not a failure of your network. +

+ )} +
+ )} + + {/* Map */} +
+
+

+ Measured geography +

+

+ Dashed circle = furthest the edge can be, given round-trip time +

+
+ +
+ + {/* Protocol verdict */} +
+ +
+

+ {result.protocolEvidence.verdict === 'http3-working' && 'HTTP/3 is working'} + {result.protocolEvidence.verdict === 'http3-absent-udp-possibly-blocked' && + 'HTTP/3 unavailable — UDP/443 may be blocked'} + {result.protocolEvidence.verdict === 'legacy-http1' && 'Connections fell back to HTTP/1.1'} + {result.protocolEvidence.verdict === 'http2-only' && 'HTTP/2 in use'} + {result.protocolEvidence.verdict === null && 'Protocol not observable'} +

+

+ {result.protocolEvidence.explanation} +

+
+
+ + {/* Waterfalls */} +
+
+

+ Connection phase breakdown +

+

+ Real timings from the Performance Timeline. Handshake phases only exist on a + connection’s first request. +

+
+ + {result.probes.map((probe) => ( +
+
+
+ {probe.target.label} + {probe.target.origin} + {probe.protocol && ( + + {probe.protocol} + + )} +
+
+ + RTT {displayMetric(probe.roundTripMs, 'ms', 1)} + + {probe.maxDistanceKm !== null && ( + + ≤ {probe.maxDistanceKm.toLocaleString()} km away + + )} +
+
+ + {probe.availability !== 'available' && ( +

+ {probe.error ?? AVAILABILITY_COPY[probe.availability]} +

+ )} +
+ ))} +
+ + )} + + setShowResponsibleModal(false)} + onConfirm={() => { + setShowResponsibleModal(false); + execute(); + }} + /> +
+ ); +}; diff --git a/src/components/HistoryLog.tsx b/src/components/HistoryLog.tsx index 41d7ada..ba98a96 100644 --- a/src/components/HistoryLog.tsx +++ b/src/components/HistoryLog.tsx @@ -124,7 +124,7 @@ export const HistoryLog: React.FC = ({ history, onHistoryUpdate {/* Type Filter Buttons */}
- {['all', 'tracert', 'portscanner', 'speedtest', 'ping', 'dns', 'webrtc', 'cidr', 'mac', 'httpprobe', 'websocket'].map((t) => ( + {['all', 'edgepath', 'tracert', 'portscanner', 'geoip', 'speedtest', 'ping', 'dns', 'webrtc', 'cidr', 'mac', 'httpprobe', 'websocket'].map((t) => (