From 6a21fb6496047c16c8e1bda0a099e7b44d6d553e Mon Sep 17 00:00:00 2001 From: TechLuddite Date: Sat, 10 Oct 2026 22:51:45 -0700 Subject: [PATCH] Remove simulated routes and launch measured tools from dashboard cards --- CLAUDE.md | 10 +- README.md | 35 +- package.json | 3 +- scripts/browser-check.mjs | 154 +++ src/App.tsx | 55 +- src/analysis/rules.test.ts | 39 +- src/analysis/rules.ts | 37 +- src/components/CaptivePortalCheck.tsx | 17 +- src/components/CidrCalculator.tsx | 28 +- src/components/Dashboard.tsx | 935 ++---------------- src/components/DnsBenchmark.tsx | 17 +- src/components/DnsResolver.tsx | 55 +- src/components/DualStackCheck.tsx | 17 +- src/components/EdgeMap.tsx | 4 +- src/components/EdgePathExplorer.tsx | 61 +- src/components/ExportPage.tsx | 5 +- src/components/GeoIpLookup.tsx | 150 ++- src/components/HistoryLog.tsx | 2 +- src/components/HttpProbe.tsx | 55 +- src/components/MacLookup.tsx | 26 +- src/components/Navbar.tsx | 2 - src/components/PingTester.tsx | 65 +- src/components/PortScanner.tsx | 197 ++-- src/components/PrivacySafetyModal.tsx | 17 +- src/components/ResponsibleNetworkingModal.tsx | 2 +- src/components/SpeedTest.tsx | 17 +- src/components/TracertMap.tsx | 526 ---------- src/components/TracertVisualizer.tsx | 503 ---------- src/components/TriagePanel.tsx | 17 +- src/components/WalkTest.tsx | 40 +- src/components/WebRtcAnalyzer.tsx | 53 +- src/components/WebSocketTester.tsx | 73 +- src/index.css | 2 +- src/types.ts | 65 +- src/utils/edgePath.test.ts | 49 +- src/utils/edgePath.ts | 52 +- src/utils/export.ts | 82 +- src/utils/geo.ts | 34 + src/utils/network.test.ts | 272 ++++- src/utils/network.ts | 152 +-- src/utils/ports.test.ts | 23 + src/utils/ports.ts | 30 + src/utils/storage.test.ts | 32 + src/utils/storage.ts | 10 +- src/utils/tracert.ts | 441 --------- 45 files changed, 1530 insertions(+), 2931 deletions(-) create mode 100644 scripts/browser-check.mjs delete mode 100644 src/components/TracertMap.tsx delete mode 100644 src/components/TracertVisualizer.tsx create mode 100644 src/utils/geo.ts create mode 100644 src/utils/ports.test.ts create mode 100644 src/utils/ports.ts create mode 100644 src/utils/storage.test.ts delete mode 100644 src/utils/tracert.ts diff --git a/CLAUDE.md b/CLAUDE.md index 90bddb5..f9c7754 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ thing this tool has that mainstream speed tests do not. boundary and let tsc check it. Every CSV generator has a test β€” keep it that way. - **Leaflet `divIcon({html})` and `bindPopup(html)` are `innerHTML` sinks.** They were fed raw user hostnames and third-party GeoIP strings, which was live XSS. Build DOM - nodes and set `textContent`. See `buildHopPopup` / `buildPopup`. + nodes and set `textContent`. See `buildPopup`. - **CSV needs formula-injection escaping**, not just quoting. `escapeCsv` prefixes `=`, `+`, `-`, `@`, tab and CR. Always use it; never hand-roll quoting. - **Third parties must stay disclosed.** `THIRD_PARTY_DISCLOSURES` in @@ -130,9 +130,11 @@ conclusions rather than reporting numbers, so it has its own version of the rule ## Verification -Run `npm run check`. For anything touching measurement or rendering, also drive the real -app β€” `npm run build && npm run preview`, then Playwright against `127.0.0.1:4173` -(Chromium at `/opt/pw-browsers/chromium`). +Run `npm run check`. For anything touching measurement or rendering, also run +`npm run test:browser` (builds the app and drives Chromium through Playwright). +Install Chromium once with `npx playwright install chromium`. The browser checks cover +dashboard launches, adjacent controls, history preservation, GeoIP missing fields and +offline speed measurements; supplement them when changing other measurement paths. **The regression test that matters most:** go offline in DevTools and run every tool. Every metric must read `β€”` with a reason. A number appearing anywhere is a P0. diff --git a/README.md b/README.md index f84a872..33a0564 100644 --- a/README.md +++ b/README.md @@ -42,18 +42,11 @@ Two rules govern it, and both are tested: `indeterminate` β€” explicitly *not* "your network is fine". A run that measured nothing concludes nothing. -### 🎯 Bottleneck attribution +### Dashboard quick launches -The dashboard headline now names the *binding constraint* rather than showing four flat bars: - -> **Grade B β€” the binding constraint is bufferbloat, not bandwidth.** Latency rose from 18 ms idle -> to 80 ms under load, an increase of 62 ms. - -It is computed by sensitivity analysis over the existing scoring function: each measured input is -lifted, one at a time, to a level past which it stops limiting the result, and whichever lift moves -the score furthest is the constraint. Those comparison values live inside the calculation and are -never reported as measurements β€” a test asserts they cannot leak into the output. Severe bufferbloat -overrides the ranking, because the numeric score does not model it and the user's experience does. +Each tool has a card with its input fields and a run button. Submit a target to open the +corresponding tool and start it with those values; use Open tool to configure it first. +Recent diagnostics stored in this browser appear below the cards. ### 🌍 Dual-stack (IPv4 / IPv6) reachability @@ -220,22 +213,15 @@ Everything a browser can genuinely observe about the path to a host: 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. +- **HTTP/3 negotiation** β€” report the protocols actually negotiated. Missing HTTP/3 does not + establish a blocked UDP/443 path: browser settings, server support and protocol discovery can + also explain it. - **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. -### 12. πŸ—ΊοΈ 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. Use the Edge Path Explorer above for measurements you can rely on. - -### 13. πŸ’Ύ History & Export -Results persist in `localStorage`. Search, filter, inspect raw JSON, and export per-tool CSVs, a +### 12. πŸ’Ύ History & Export +Results persist in `localStorage`. Legacy simulated route records are excluded from the UI and exports; their stored data is preserved. Search, filter, inspect raw JSON, and export per-tool CSVs, a master summary, or a bundled ZIP with a manifest. --- @@ -253,6 +239,7 @@ npm run dev # http://localhost:5173 npm run build # static bundle in dist/ npm run preview npm run check # typecheck + lint + tests +npm run test:browser # build + browser regression checks (requires Playwright Chromium) ``` There is no server component. `npm run build` emits static files that can be hosted anywhere. @@ -275,7 +262,7 @@ without touching it, so the following go directly from your browser to third par | `cloudflare-dns.com`, `dns.google` | Every domain you resolve, over encrypted DoH | | `cloudflare-dns.com`, `dns.google`, `dns.quad9.net`, `dns10.quad9.net`, `freedns.controld.com`, `doh.sb`, `public.dns.iij.jp` | Your IP and every name the DNS benchmark queries β€” ~20 each, most randomly generated, which identifies the run to each provider | | `dnssec-failed.org`, `internetsociety.org` | Not contacted; their names are the DNSSEC test pair sent to the resolvers above | -| `ipwho.is`, `ipapi.co`, `freeipapi.com` | Your public IP on opening the GeoIP tool, and every IP or domain you look up | +| `ipwho.is`, `ipapi.co` | Your public IP when you run the GeoIP lookup, and every IP or domain you look up | | `1.1.1.1`, `one.one.one.one`, `dns.quad9.net`, `doh.opendns.com`, `en.wikipedia.org` | Your IP, as latency probe targets, and as the two halves of the resolver test | | `ipv4.icanhazip.com`, `ipv6.icanhazip.com`, `api4.ipify.org`, `api6.ipify.org` | Your IP, during the dual-stack check β€” each answers on one address family only | | `cp.cloudflare.com` | Your IP, during the captive-portal check, and only when NetReady is opened over plain `http` | diff --git a/package.json b/package.json index 5f7c91c..5bee5a4 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "test": "vitest run", "test:watch": "vitest", "check": "npm run typecheck && npm run lint && npm run test", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test:browser": "npm run build && node scripts/browser-check.mjs" }, "dependencies": { "jszip": "^3.10.1", diff --git a/scripts/browser-check.mjs b/scripts/browser-check.mjs new file mode 100644 index 0000000..ebed45b --- /dev/null +++ b/scripts/browser-check.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; + +// Real UI checks with controlled probe responses; never contact diagnostic targets. +const origin = 'http://127.0.0.1:4175'; +const server = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', '4175', '--strictPort'], { stdio: 'pipe' }); +let serverOutput = ''; +server.stdout.on('data', chunk => { serverOutput += chunk; }); +server.stderr.on('data', chunk => { serverOutput += chunk; }); +let browser; +const errors = []; +try { + for (let attempt = 0; ; attempt++) { + try { if ((await fetch(origin)).ok) break; } catch { /* server is starting */ } + if (attempt > 100 || server.exitCode !== null) throw new Error(`Preview failed: ${serverOutput}`); + await new Promise(resolve => setTimeout(resolve, 100)); + } + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + await context.addInitScript(() => { + if (window.top !== window) return; + localStorage.setItem('netready_responsible_networking_accepted', 'true'); + if (!localStorage.getItem('netready_history_v1')) localStorage.setItem('netready_history_v1', JSON.stringify([ + { id: 'old-route', type: 'tracert', timestamp: 1, title: 'Legacy simulated route', summary: 'Invented hops', data: {} }, + { id: 'saved-dns', type: 'dns', timestamp: 2, title: 'Saved measured DNS', summary: 'Saved diagnostic', data: {} }, + ])); + }); + const page = await context.newPage(); + page.on('pageerror', error => errors.push(error.message)); + const requests = []; + let geoResponse = { ip: '203.0.113.2', city: 'Example City', success: true }; + await context.route('**/*', async route => { + const url = route.request().url(); + if (url.startsWith(origin)) return route.continue(); + requests.push(url); + if (url.includes('cloudflare-dns.com/dns-query') || url.includes('dns.google/resolve')) return route.fulfill({ json: { Status: 0, Answer: [{ name: 'example.com.', type: 1, TTL: 60, data: '203.0.113.2' }] }, headers: { 'access-control-allow-origin': '*' } }); + if (url.startsWith('https://ipwho.is/')) return route.fulfill({ json: geoResponse, headers: { 'access-control-allow-origin': '*' } }); + return route.abort(); + }); + const dashboard = async () => { + await page.getByRole('button', { name: 'Dashboard', exact: true }).click(); + await page.getByRole('heading', { name: 'Recent diagnostic history' }).waitFor(); + }; + const card = title => page.locator('article').filter({ has: page.getByRole('heading', { name: title, exact: true }) }); + await page.goto(origin); + assert.equal(await page.locator('article').count(), 18); + assert.equal(await page.getByText('Network Readiness Score', { exact: true }).count(), 0); + assert.equal(await page.getByText('Route Model', { exact: true }).count(), 0); + assert.equal(await page.getByText('Legacy simulated route', { exact: true }).count(), 0); + assert.equal(await page.getByText('Saved measured DNS', { exact: true }).count(), 1); + + await card('DNS-over-HTTPS Resolver').getByLabel('Domain', { exact: true }).fill('example.com'); + await card('DNS-over-HTTPS Resolver').getByRole('button', { name: 'Resolve', exact: true }).click(); + await page.waitForFunction(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.data?.domain === 'example.com')); + assert.equal(requests.filter(url => url.includes('name=example.com')).length, 1, 'launch runs DNS exactly once'); + assert.equal(await page.locator('main input[type=text]').inputValue(), 'example.com'); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.id === 'old-route')), true, 'old records preserved in storage'); + await dashboard(); + const beforeOpen = requests.length; + await card('DNS-over-HTTPS Resolver').getByRole('button', { name: 'Open tool', exact: true }).click(); + await page.waitForTimeout(150); + assert.equal(requests.length, beforeOpen, 'Open tool does not reuse a launch'); + await dashboard(); + await card('DNS-over-HTTPS Resolver').getByLabel('Domain', { exact: true }).fill('second.example'); + await card('DNS-over-HTTPS Resolver').getByRole('button', { name: 'Resolve', exact: true }).click(); + await page.waitForFunction(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.data?.domain === 'second.example')); + assert.equal(requests.filter(url => url.includes('name=second.example')).length, 1); + + await dashboard(); + await card('Subnet & CIDR Calculator').getByLabel('IPv4 CIDR').fill('10.20.30.0/invalid'); + await card('Subnet & CIDR Calculator').getByRole('button', { name: 'Calculate', exact: true }).click(); + assert.equal(await page.getByRole('heading', { name: 'Recent diagnostic history' }).count(), 1, 'malformed CIDR cannot launch invalid calculations'); + await card('Subnet & CIDR Calculator').getByLabel('IPv4 CIDR').fill('10.20.30.0/28'); + await card('Subnet & CIDR Calculator').getByRole('button', { name: 'Calculate', exact: true }).click(); + assert.equal(await page.locator('main input[type=text]').inputValue(), '10.20.30.0'); + assert.match(await page.locator('main').innerText(), /10\.20\.30\.0\/28/); + await dashboard(); + await card('MAC / OUI Lookup').getByLabel('MAC address or prefix').fill('00:1A:2B'); + await card('MAC / OUI Lookup').getByRole('button', { name: 'Look up', exact: true }).click(); + assert.equal(await page.locator('main input[type=text]').inputValue(), '00:1A:2B'); + + await dashboard(); + await card('GeoIP & ISP Lookup').getByLabel('IP or hostname (optional)').fill('203.0.113.2'); + await card('GeoIP & ISP Lookup').getByRole('button', { name: 'Look up', exact: true }).click(); + await page.getByText(/Example City/).first().waitFor(); + assert.equal(requests.filter(url => url === 'https://ipwho.is/203.0.113.2').length, 1, 'GeoIP launches only requested target'); + assert.equal(await page.locator('main iframe').count(), 0, 'unknown coordinates do not show a map'); + assert.doesNotMatch(await page.locator('main').innerText(), /0\.0000|\bNO\b/); + const geo = await page.evaluate(() => JSON.parse(localStorage.getItem('netready_history_v1')).find(item => item.type === 'geoip').data); + assert.equal(geo.lat, null); assert.equal(geo.isVpn, null); + + geoResponse = { ...geoResponse, latitude: 0, longitude: 0, security: { proxy: false, vpn: false } }; + await page.locator('main form button[type=submit]').click(); + await page.locator('main iframe').waitFor(); + assert.match(await page.locator('main').innerText(), /0\.0000, 0\.0000/); + + // Every configurable tool keeps its primary run control in the input/settings panel. + const layoutCases = [ + ['Ping & Jitter', /Start Ping Sequence/, 'input[type=text]'], + ['DNS-over-HTTPS Resolver', /Resolve DNS/, 'input[type=text]'], + ['Local Port Scanner', /Launch Scan/, 'input[type=text]'], + ['HTTP Probe', /Probe Endpoint/, 'input[type=text]'], + ['WebSocket Tester', /Test WebSocket Connection/, 'input[type=text]'], + ['Edge Path Explorer', /Explore/, 'input[type=text]'], + ['WebRTC ICE Analyzer', /Inspect ICE Candidates/, 'input[type=text], select'], + ['Walk & Test', /Start walking/, '#walk-start-spot'], + ]; + for (const [title, buttonName, selector] of layoutCases) { + await dashboard(); + await card(title).getByRole('button', { name: 'Open tool', exact: true }).click(); + const input = page.locator(`main ${selector}`).last(); + const button = page.locator('main').getByRole('button', { name: buttonName }).first(); + const inputBox = await input.boundingBox(); const buttonBox = await button.boundingBox(); + assert(inputBox && buttonBox, `${title} controls visible`); + assert(Math.abs(buttonBox.y - inputBox.y) < 320, `${title} run control beside inputs`); + } + await dashboard(); + const beforeGeoOpen = requests.length; + await card('GeoIP & ISP Lookup').getByRole('button', { name: 'Open tool', exact: true }).click(); + await page.waitForTimeout(150); + assert.equal(requests.length, beforeGeoOpen, 'opening GeoIP does not run a lookup'); + await dashboard(); + await page.evaluate(() => localStorage.removeItem('netready_responsible_networking_accepted')); + await card('HTTP Probe').getByLabel('HTTP or HTTPS URL').fill('https://example.com/diagnostic'); + const beforeConsent = requests.length; + await card('HTTP Probe').getByRole('button', { name: 'Probe', exact: true }).click(); + await page.getByText('Responsible Networking Warning', { exact: true }).waitFor(); + assert.equal(requests.length, beforeConsent, 'dashboard submission retains authorization gate'); + await page.getByText('I confirm that I own or have explicit authorization to scan and test target hosts.', { exact: true }).click(); + await page.getByRole('button', { name: 'Confirm & Continue', exact: true }).click(); + await page.waitForFunction(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.type === 'httpprobe')); + assert.equal(await page.locator('main input[type=text]').inputValue(), 'https://example.com/diagnostic'); + assert(requests.some(url => url.startsWith('https://example.com/diagnostic'))); + await dashboard(); + await card('Local Port Scanner').getByLabel('Host, subnet or range').fill('127.0.0.1'); + await card('Local Port Scanner').getByLabel('Ports', { exact: true }).fill('1-65535'); + const beforeInvalidScan = requests.length; + await card('Local Port Scanner').getByRole('button', { name: 'Run test', exact: true }).click(); + await page.locator('main').getByText(/Select at most 1000/).first().waitFor(); + assert.equal(requests.length, beforeInvalidScan, 'oversized ports do not launch a partial scan'); + await dashboard(); + await context.setOffline(true); + await card('Speed & Bandwidth').getByRole('button', { name: 'Run test', exact: true }).click(); + await page.waitForFunction(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.type === 'speedtest'), { timeout: 60000 }); + const speed = await page.evaluate(() => JSON.parse(localStorage.getItem('netready_history_v1')).find(item => item.type === 'speedtest').data); + for (const key of ['downloadSpeed', 'uploadSpeed', 'ping', 'jitter', 'bufferbloatScore']) assert.equal(speed[key], null, `offline ${key} unavailable`); + assert(speed.failures.length > 0); + assert.equal(errors.length, 0, errors.join('\n')); + console.log('Browser checks passed: quick launches, history preservation, input/button placement, GeoIP unknown/zero values, offline speed measurements.'); +} finally { + await browser?.close(); + server.kill('SIGTERM'); +} diff --git a/src/App.tsx b/src/App.tsx index 5a46264..ff28334 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { ToolTab, NetworkConnectionInfo, HistoryItem } from './types'; +import { ToolTab, ToolLaunch, NetworkConnectionInfo, HistoryItem } from './types'; import { getNetworkConnectionInfo } from './utils/network'; import { getHistory, getLocalStorageSizeBytes } from './utils/storage'; import { Navbar } from './components/Navbar'; @@ -10,7 +10,6 @@ import { DualStackCheck } from './components/DualStackCheck'; import { DnsBenchmark } from './components/DnsBenchmark'; import { CaptivePortalCheck } from './components/CaptivePortalCheck'; import { EdgePathExplorer } from './components/EdgePathExplorer'; -import { TracertVisualizer } from './components/TracertVisualizer'; import { PortScanner } from './components/PortScanner'; import { GeoIpLookup } from './components/GeoIpLookup'; import { SpeedTest } from './components/SpeedTest'; @@ -27,6 +26,15 @@ import { HardDrive } from 'lucide-react'; export default function App() { const [activeTab, setActiveTab] = useState('dashboard'); + const [launch, setLaunch] = useState(); + const navigate = (tab: ToolTab) => { + setLaunch(undefined); + setActiveTab(tab); + }; + const launchTool = (tab: ToolTab, values: Record) => { + setLaunch({ id: Date.now(), tab, values }); + setActiveTab(tab); + }; const [connInfo, setConnInfo] = useState(getNetworkConnectionInfo()); const [history, setHistory] = useState([]); const [storageBytes, setStorageBytes] = useState(0); @@ -69,7 +77,7 @@ export default function App() { {/* Navigation Header */} @@ -78,68 +86,63 @@ export default function App() {
{activeTab === 'dashboard' && ( )} - {activeTab === 'walktest' && } + {activeTab === 'walktest' && } - {activeTab === 'triage' && } + {activeTab === 'triage' && } - {activeTab === 'dualstack' && } - {activeTab === 'dnsbench' && } + {activeTab === 'dualstack' && } + {activeTab === 'dnsbench' && } - {activeTab === 'captive' && } + {activeTab === 'captive' && } {activeTab === 'edgepath' && ( - - )} - - {activeTab === 'tracert' && ( - + )} {activeTab === 'portscanner' && ( - + )} {activeTab === 'geoip' && ( - + )} {activeTab === 'speedtest' && ( - + )} {activeTab === 'ping' && ( - + )} {activeTab === 'dns' && ( - + )} {activeTab === 'webrtc' && ( - + )} {activeTab === 'cidr' && ( - + )} {activeTab === 'mac' && ( - + )} {activeTab === 'httpprobe' && ( - + )} {activeTab === 'websocket' && ( - + )} {activeTab === 'history' && ( diff --git a/src/analysis/rules.test.ts b/src/analysis/rules.test.ts index 7006ca4..aeaf943 100644 --- a/src/analysis/rules.test.ts +++ b/src/analysis/rules.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { RULES, THRESHOLDS } from './rules'; import { evaluateRules } from './engine'; import { createSnapshot } from './snapshot'; +import { assessProtocols } from '../utils/edgePath'; import type { CaptivePortalResult, DnsIntegrityResult, @@ -266,7 +267,7 @@ describe('cdn reachability rules', () => { }); describe('protocol rules', () => { - it('fires on HTTP/3 falling back to HTTP/2', () => { + it('treats absent HTTP/3 as possible explanations rather than a measured fault', () => { const s = createSnapshot({ edge: edge({ protocolEvidence: { @@ -279,10 +280,36 @@ describe('protocol rules', () => { }, }), }); - expect(fired(s)).toContain('udp-443-blocked'); + const finding = evaluateRules(s).find((f) => f.ruleId === 'udp-443-blocked'); + expect(finding).toBeDefined(); + expect(finding!.confidence).toBe('possible'); + expect(finding!.severity).toBe('informational'); + expect(finding!.verdict).not.toMatch(/browser tried|fell back|being blocked/); + expect(finding!.verdict).toContain('do not establish which cause'); + }); + + it('quotes mixed protocols without claiming every response was HTTP/2', () => { + const probes = [ + { ...edgeProbe('a', 'available'), protocol: 'h2' }, + { ...edgeProbe('b', 'available'), protocol: 'http/1.1' }, + ]; + const s = createSnapshot({ edge: edge({ probes, protocolEvidence: assessProtocols(probes) }) }); + const finding = evaluateRules(s).find((f) => f.ruleId === 'udp-443-blocked'); + expect(finding!.evidence[0].observation).toBe( + 'HTTP/2 responses: 1; HTTP/1.x responses: 1; HTTP/3 responses: 0', + ); + expect(finding!.confidence).toBe('possible'); + }); + + it('draws no protocol finding from unknown or unreadable evidence', () => { + for (const protocol of ['unknown', null]) { + const probes = [{ ...edgeProbe('a', 'available'), protocol }]; + const s = createSnapshot({ edge: edge({ probes, protocolEvidence: assessProtocols(probes) }) }); + expect(fired(s)).toEqual([]); + } }); - it('fires on a total fallback to HTTP/1.1', () => { + it('treats observed HTTP/1.x as a possible cause with no claimed fallback', () => { const s = createSnapshot({ edge: edge({ protocolEvidence: { @@ -295,7 +322,11 @@ describe('protocol rules', () => { }, }), }); - expect(fired(s)).toContain('legacy-http1'); + const finding = evaluateRules(s).find((f) => f.ruleId === 'legacy-http1'); + expect(finding).toBeDefined(); + expect(finding!.confidence).toBe('possible'); + expect(finding!.severity).toBe('informational'); + expect(finding!.verdict).not.toContain('fell back'); }); it('stays quiet when HTTP/3 works', () => { diff --git a/src/analysis/rules.ts b/src/analysis/rules.ts index 96017ed..8cdd49a 100644 --- a/src/analysis/rules.ts +++ b/src/analysis/rules.ts @@ -251,26 +251,26 @@ export const RULES: Rule[] = [ { id: 'udp-443-blocked', - title: 'HTTP/3 is being forced back to HTTP/2', + title: 'HTTP/3 was not observed', layer: 'local-network', consumes: ['edge.protocolEvidence'], evaluate: (s) => { if (s.edge?.protocolEvidence.verdict !== 'http3-absent-udp-possibly-blocked') return null; const e = s.edge.protocolEvidence; + if (e.h3Count > 0 || e.h2Count + e.http1Count === 0) return null; return { - confidence: 'likely', - severity: 'degrading', + confidence: 'possible', + severity: 'informational', verdict: - 'Every origin that advertises HTTP/3 was reached over HTTP/2 instead. The browser tried ' + - 'QUIC and fell back, which points at UDP port 443 being blocked upstream. Things still ' + - 'work; they just lose the faster, loss-resilient path.', + 'The readable responses used other protocols. Browser settings, protocol discovery, ' + + 'connection reuse, or a blocked UDP path could explain the absence of HTTP/3; ' + + 'these measurements do not establish which cause applies.', remediation: [ - 'Check the router or firewall for a rule blocking outbound UDP 443 β€” some "QUIC blocking" toggles are on by default.', - 'On a corporate network this is often deliberate, to keep traffic inspectable.', - 'The cost is mainly on lossy links such as mobile and congested Wi-Fi.', + 'Check whether the browser enables HTTP/3 and repeat the probes after protocol discovery.', + 'If HTTP/3 works on another network with the same browser, check router or firewall settings for outbound UDP restrictions.', ], evidence: [ - ev('edge.protocolEvidence', `${e.h2Count} origin(s) negotiated h2, none negotiated h3`), + ev('edge.protocolEvidence', `HTTP/2 responses: ${e.h2Count}; HTTP/1.x responses: ${e.http1Count}; HTTP/3 responses: ${e.h3Count}`), ], }; }, @@ -278,21 +278,22 @@ export const RULES: Rule[] = [ { id: 'legacy-http1', - title: 'Every connection fell back to HTTP/1.1', + title: 'Only HTTP/1.x was observed', layer: 'local-network', consumes: ['edge.protocolEvidence'], evaluate: (s) => { if (s.edge?.protocolEvidence.verdict !== 'legacy-http1') return null; + const e = s.edge.protocolEvidence; + if (e.http1Count === 0 || e.h2Count > 0 || e.h3Count > 0) return null; return { - confidence: 'likely', - severity: 'degrading', + confidence: 'possible', + severity: 'informational', verdict: - 'Nothing negotiated better than HTTP/1.1, though all of these origins support HTTP/2. ' + - 'A proxy or TLS-inspecting middlebox in the path is the usual reason, and it costs both ' + - 'throughput and latency.', + 'The readable responses used HTTP/1.x. Browser settings, server configuration, ' + + 'or a proxy may explain this; the measurements do not identify the cause.', remediation: [ - 'Look for an explicit proxy configured in the OS or browser network settings.', - 'On a managed device, an inspection appliance is likely doing this deliberately.', + 'Compare another browser and check its protocol settings.', + 'Check for a configured proxy if the same destinations use newer protocols elsewhere.', ], evidence: [ ev( diff --git a/src/components/CaptivePortalCheck.tsx b/src/components/CaptivePortalCheck.tsx index b88fbf6..7c616cb 100644 --- a/src/components/CaptivePortalCheck.tsx +++ b/src/components/CaptivePortalCheck.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import type { ToolLaunch } from '../types'; +import React, { useState, useEffect, useRef } from 'react'; import { ShieldQuestion, Play, @@ -31,6 +32,7 @@ import { saveHistoryItem } from '../utils/storage'; */ interface CaptivePortalCheckProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } @@ -73,7 +75,7 @@ const OUTCOME_STYLE: Record< const reachabilityWord = (v: boolean | null): string => v === null ? 'not checked' : v ? 'answered' : 'no response'; -export const CaptivePortalCheck: React.FC = ({ onHistoryUpdate }) => { +export const CaptivePortalCheck: React.FC = ({ onHistoryUpdate, launch }) => { const [portal, setPortal] = useState(null); const [dns, setDns] = useState(null); const [isRunning, setIsRunning] = useState(false); @@ -108,6 +110,17 @@ export const CaptivePortalCheck: React.FC = ({ onHistor } }; + 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 run(); + }, 0); + return () => window.clearTimeout(timer); + }); + const failures = [...(portal?.failures ?? []), ...(dns?.failures ?? [])]; return ( diff --git a/src/components/CidrCalculator.tsx b/src/components/CidrCalculator.tsx index a410de5..82e436d 100644 --- a/src/components/CidrCalculator.tsx +++ b/src/components/CidrCalculator.tsx @@ -1,3 +1,4 @@ +import type { ToolLaunch } from '../types'; import React, { useState } from 'react'; import { Calculator, Copy, CheckCircle2, Bookmark, Layers } from 'lucide-react'; import { calculateCidr, generateSubnets } from '../utils/cidr'; @@ -5,12 +6,13 @@ import { HistoryItem } from '../types'; import { saveHistoryItem } from '../utils/storage'; interface CidrCalculatorProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } -export const CidrCalculator: React.FC = ({ onHistoryUpdate }) => { - const [ip, setIp] = useState('192.168.1.100'); - const [prefix, setPrefix] = useState(24); +export const CidrCalculator: React.FC = ({ onHistoryUpdate, launch }) => { + const [ip, setIp] = useState(launch?.values.cidr?.split('/')[0] ?? '192.168.1.100'); + const [prefix, setPrefix] = useState(Number(launch?.values.cidr?.split('/')[1] ?? 24)); const [subnetPrefix, setSubnetPrefix] = useState(26); const [copiedKey, setCopiedKey] = useState(null); @@ -52,16 +54,6 @@ export const CidrCalculator: React.FC = ({ onHistoryUpdate 100% offline client-side IP subnet calculator. Determines network bounds, broadcast addresses, usable host ranges, binary representation, and subnetting plans.

- - {cidr && ( - - )} {/* Input Form Bar */} @@ -104,6 +96,16 @@ export const CidrCalculator: React.FC = ({ onHistoryUpdate + + {cidr && ( + + )} {/* Results View */} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index b6e98b2..ba6c64e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,859 +1,96 @@ -import React, { useState, useEffect } from 'react'; -import { - Gauge, - Radio, - Globe, - Cpu, - Calculator, - Search, - Server, - Terminal, - History, - Zap, - ArrowRight, - ShieldCheck, - RefreshCw, - Clock, - Radar, - GitCommit, - Archive, - Compass, - Stethoscope, - Network, - ShieldQuestion, - Timer, - Footprints, - MapPin, -} from 'lucide-react'; -import { ToolTab, NetworkConnectionInfo, SpeedTestResult, PingResult, HistoryItem } from '../types'; -import { - runSpeedTest, - executePingBatch, - gatherWebRtcCandidates, - calculateNetReadyScore, - createId, -} from '../utils/network'; -import { displayMetric } from './MetricValue'; -import { BottleneckSummary } from './BottleneckSummary'; -import { saveHistoryItem } from '../utils/storage'; -import { ResponsibleNetworkingModal, isResponsibleNetworkingAccepted } from './ResponsibleNetworkingModal'; - -/** - * One category readiness bar. A category with no measurement behind it shows an - * em-dash and an empty track, not a zero-width bar that reads as "scored 0". - */ -const ScoreBar: React.FC<{ label: string; score: number | null }> = ({ label, score }) => ( -
-
- {label} - - {score === null ? 'β€”' : `${score}%`} - -
-
- {score !== null && ( -
- )} -
-
-); - -interface DashboardProps { - setActiveTab: (tab: ToolTab) => void; - connInfo: NetworkConnectionInfo; - history: HistoryItem[]; - onHistoryUpdate: () => void; +import { useState, type ComponentType, type FormEvent } from 'react'; +import { Gauge, Radio, Globe, Cpu, Calculator, Search, Server, Terminal, History, Radar, Archive, Compass, Stethoscope, Network, ShieldQuestion, Timer, Footprints, ArrowRight } from 'lucide-react'; +import type { ToolTab, HistoryItem } from '../types'; + +interface QuickField { + key: string; + label: string; + placeholder: string; + optional?: boolean; + pattern?: string; } - -export const Dashboard: React.FC = ({ - setActiveTab, - connInfo, - history, - onHistoryUpdate, -}) => { - const [isAuditing, setIsAuditing] = useState(false); - const [auditStep, setAuditStep] = useState(''); - const [latestSpeed, setLatestSpeed] = useState(null); - const [latestPing, setLatestPing] = useState(null); - const [publicIp, setPublicIp] = useState(null); - const [showResponsibleModal, setShowResponsibleModal] = useState(false); - const [auditError, setAuditError] = useState(null); - - // Load existing test results from history on mount (do NOT auto-run any test) - useEffect(() => { - const speedItem = history.find((h) => h.type === 'speedtest'); - if (speedItem) setLatestSpeed(speedItem.data); - - const pingItem = history.find((h) => h.type === 'ping'); - if (pingItem) setLatestPing(pingItem.data); - }, [history]); - - // Null until enough has been measured to score anything. The score is never - // computed from stand-in values. - const netReadyScore = calculateNetReadyScore(latestSpeed, latestPing); - const hasMeasured = netReadyScore !== null; - - const executeFullAudit = async () => { - setIsAuditing(true); - setAuditError(null); - try { - setAuditStep('1 of 3 β€” measuring latency and jitter'); - const pingRes = await executePingBatch('https://1.1.1.1/cdn-cgi/trace', 'Cloudflare (1.1.1.1)', 8); - setLatestPing(pingRes); - - setAuditStep('2 of 3 β€” measuring bandwidth via the Cloudflare edge'); - const speedRes = await runSpeedTest(); - setLatestSpeed(speedRes); - - setAuditStep('3 of 3 β€” gathering WebRTC ICE candidates'); - const rtcRes = await gatherWebRtcCandidates(); - if (rtcRes.publicIps.length > 0) { - setPublicIp(rtcRes.publicIps[0]); - } - - const score = calculateNetReadyScore(speedRes, pingRes); - const auditItem: HistoryItem = { - id: createId('audit'), - type: 'speedtest', - timestamp: Date.now(), - title: score - ? `Full audit: grade ${score.grade} (${score.overallScore}%)` - : 'Full audit: not scorable β€” no measurement succeeded', - summary: `Cloudflare CDN | Download: ${displayMetric( - speedRes.downloadSpeed, - 'Mbps', - 2, - )} | Ping: ${displayMetric(pingRes.avgPing, 'ms')} | Jitter: ${displayMetric( - pingRes.jitter, - 'ms', - )}`, - data: speedRes, - }; - - saveHistoryItem(auditItem); - onHistoryUpdate(); - } catch (e) { - console.error('Audit failed:', e); - setAuditError( - e instanceof Error - ? `The audit could not complete: ${e.message}` - : 'The audit could not complete.', - ); - } finally { - setIsAuditing(false); - setAuditStep(''); - } - }; - - const handleRunFullAudit = () => { - if (!isResponsibleNetworkingAccepted()) { - setShowResponsibleModal(true); - return; - } - executeFullAudit(); - }; - - const getGradeColor = (grade?: string) => { - if (!grade) return 'text-slate-500 bg-slate-800/50 border-slate-700/50'; - switch (grade) { - case 'A+': - case 'A': - return 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'; - case 'B': - return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30'; - case 'C': - return 'text-amber-400 bg-amber-500/10 border-amber-500/30'; - default: - return 'text-rose-400 bg-rose-500/10 border-rose-500/30'; - } +interface ToolCard { + tab: ToolTab; + title: string; + description: string; + icon: ComponentType<{ className?: string }>; + fields?: QuickField[]; + action?: string; +} +const TOOLS: ToolCard[] = [ + { tab: 'walktest', title: 'Walk & Test', description: 'Measure HTTPS response times while you move between spots.', icon: Footprints, fields: [{ key: 'spot', label: 'Starting spot (optional)', placeholder: 'Lobby', optional: true }] }, + { tab: 'triage', title: 'Me or the Internet?', description: 'Run network checks and see findings backed by their measurements.', icon: Stethoscope }, + { tab: 'dualstack', title: 'IPv4 / IPv6 Reachability', description: 'Check each address family with independent probe providers.', icon: Network }, + { tab: 'captive', title: 'Portal & DNS Hijack', description: 'Check known responses for signs of interception or resolver trouble.', icon: ShieldQuestion }, + { tab: 'edgepath', title: 'Edge Path Explorer', description: 'Measure connection phases and identify the CDN edge that answered.', icon: Compass, fields: [{ key: 'target', label: 'Hostname or URL (optional)', placeholder: 'example.com', optional: true }] }, + { tab: 'portscanner', title: 'Local Port Scanner', description: 'Browser service probes. Port state is inferred from connection timing.', icon: Radar, fields: [{ key: 'target', label: 'Host, subnet or range', placeholder: '192.168.1.1' }, { key: 'ports', label: 'Ports', placeholder: '80,443,3000,8080' }] }, + { tab: 'geoip', title: 'GeoIP & ISP Lookup', description: 'Look up provider-reported location and network details.', icon: Globe, fields: [{ key: 'target', label: 'IP or hostname (optional)', placeholder: 'Leave blank for your public IP', optional: true }], action: 'Look up' }, + { tab: 'speedtest', title: 'Speed & Bandwidth', description: 'Measure download, upload, latency and behaviour under load.', icon: Gauge }, + { tab: 'ping', title: 'Ping & Jitter', description: 'Measure HTTPS round trips to your target, including jitter and unanswered probes.', icon: Radio, fields: [{ key: 'target', label: 'IP, hostname or URL', placeholder: 'example.com' }] }, + { tab: 'dns', title: 'DNS-over-HTTPS Resolver', description: 'Query DNS records over an encrypted HTTPS connection.', icon: Globe, fields: [{ key: 'domain', label: 'Domain', placeholder: 'example.com' }], action: 'Resolve' }, + { tab: 'dnsbench', title: 'DNS Resolver Benchmark', description: 'Compare measured query times across public DoH resolvers.', icon: Timer }, + { tab: 'webrtc', title: 'WebRTC ICE Analyzer', description: 'Gather ICE candidates and inspect what your browser exposes.', icon: Cpu }, + { tab: 'cidr', title: 'Subnet & CIDR Calculator', description: 'Calculate address ranges and subnet masks locally.', icon: Calculator, fields: [{ key: 'cidr', label: 'IPv4 CIDR', placeholder: '192.168.1.0/24', pattern: '[0-9]{1,3}(\\.[0-9]{1,3}){3}/([0-9]|[12][0-9]|3[0-2])' }], action: 'Calculate' }, + { tab: 'mac', title: 'MAC / OUI Lookup', description: 'Look up vendor prefixes in the bundled offline database.', icon: Search, fields: [{ key: 'mac', label: 'MAC address or prefix', placeholder: '00:1A:2B:3C:4D:5E' }], action: 'Look up' }, + { tab: 'httpprobe', title: 'HTTP Probe', description: 'Measure response time and inspect status and visible headers.', icon: Server, fields: [{ key: 'url', label: 'HTTP or HTTPS URL', placeholder: 'https://example.com' }], action: 'Probe' }, + { tab: 'websocket', title: 'WebSocket Tester', description: 'Measure a WebSocket handshake and application echo round trips.', icon: Terminal, fields: [{ key: 'url', label: 'WebSocket URL', placeholder: 'wss://your-echo-server.example' }] }, + { tab: 'history', title: 'Diagnostic History', description: 'Search and inspect results saved in this browser.', icon: History, action: 'Open history' }, + { tab: 'export', title: 'Data Export', description: 'Download saved measurements as CSV, JSON or a bundled ZIP.', icon: Archive, action: 'Open export' }, +]; + +function QuickToolCard({ tool, onLaunch, onOpen }: { tool: ToolCard; onLaunch: (tab: ToolTab, values: Record) => void; onOpen: (tab: ToolTab) => void }) { + const [values, setValues] = useState>({}); + const Icon = tool.icon; + const submit = (event: FormEvent) => { + event.preventDefault(); + if (tool.tab === 'history' || tool.tab === 'export') onOpen(tool.tab); + else onLaunch(tool.tab, Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value.trim()]))); }; - return ( -
- {/* Hero Header & NetReady Readiness Score */} -
- {/* Readiness Score Badge */} -
-
- -
-
-
- - - NetReady Connection Benchmark - -
-

- Network Readiness Score -

-

- Derived from measured round-trip latency, jitter and throughput against the - Cloudflare edge. Categories whose inputs could not be measured are left blank - rather than scored from typical values. -

-
- - {/* Score. `netReadyScore` is null until something has actually been - measured β€” there is no "typical value" fallback behind it. */} -
-
-
- {netReadyScore?.grade ?? '--'} -
-
Grade
-
- -
- -
-
- {netReadyScore?.overallScore ?? '--'} - % -
-
Overall score
-
-
-
- - {/* Bottleneck attribution. - This is the headline now. Four flat bars showed which categories - scored low without ever saying which measured input was dragging - them down, so the one question a user actually has β€” "what do I - change?" β€” went unanswered. The bars are still below, demoted to - supporting detail. */} -
- -
- - {/* Application category suitability */} -
-
- Category suitability -
-
- - - - -
-
- - {auditError && ( -
- {auditError} -
- )} - - {/* Audit action */} -
-
- - - {isAuditing && ( - - {auditStep} - - )} -
- - {/* Every generated finding is shown. Previously only the first of - three was rendered and the other two were dead code. */} - {netReadyScore ? ( -
    - {netReadyScore.details.map((d) => ( -
  • - - - {d} -
  • - ))} -
- ) : ( -

- No baseline yet. Run the audit to measure latency, jitter and throughput. -

- )} -
-
- - {/* Live Browser Connection Card */} -
-
-
-

- Browser Interface Status -

- -
- -
-
- Online Status: - - {connInfo.isOnline ? 'Connected' : 'Disconnected'} - -
- -
- Downlink Estimate: - - {connInfo.downlink ? `${connInfo.downlink} Mbps` : 'N/A'} - -
- -
- Network Type: - - {connInfo.effectiveType || connInfo.type || 'Standard'} - -
- -
- Estimated RTT: - - {connInfo.rtt ? `${connInfo.rtt} ms` : 'N/A'} - -
- - {publicIp && ( -
- Public IP: - {publicIp} -
- )} -
-
- -
- Local Storage Persistence - Active -
-
-
- - {/* Tool Launch Grid */} +
-

- Local Network Diagnostic Tools -

- -
- {/* Walk & Test β€” the only tool here that expects you to move */} -
setActiveTab('walktest')} - className="group bg-gradient-to-br from-teal-950/50 via-slate-900 to-slate-900 border border-teal-500/40 hover:border-teal-400 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-xl hover:shadow-teal-500/10 hover:-translate-y-0.5" - > -
- -
-
-

- Walk & Test -

- - New - -
-

- Ten consumer and business destinations probed round after round while you walk the - building. Name each spot as you reach it and compare them side by side. -

-
- - Survey the building - -
-
- - {/* Triage β€” the answer layer */} -
setActiveTab('triage')} - className="group bg-gradient-to-br from-cyan-950/50 via-slate-900 to-slate-900 border border-cyan-500/40 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" - > -
- -
-
-

- Is it me or the internet? -

- - New - -
-

- One run walks the whole decision tree and returns a verdict with ranked causes and - fixes β€” deterministic rules, no model, every finding backed by its measurements. -

-
- Run triage - -
-
- - {/* Dual-stack */} -
setActiveTab('dualstack')} - className="group bg-slate-900 border border-slate-800 hover:border-indigo-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5" - > -
- -
-
-

- IPv4 / IPv6 Reachability -

- - New - -
-

- Call hosts that publish only an A record and only an AAAA record, and see which - family actually carries traffic from here. -

-
- Check both families - -
-
- - {/* DNS resolver benchmark */} -
setActiveTab('dnsbench')} - className="group bg-slate-900 border border-slate-800 hover:border-sky-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5" - > -
- -
-
-

- DNS Resolver Benchmark -

- - New - -
-

- Cached, uncached and .com lookups timed across public DoH resolvers β€” the split Steve - Gibson designed for GRC’s DNS Benchmark, as far as a browser can take it. -

-
- Compare resolvers - -
-
- - {/* Captive portal & DNS hijack */} -
setActiveTab('captive')} - className="group bg-slate-900 border border-slate-800 hover:border-amber-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5" - > -
- -
-
-

- Portal & DNS Hijack -

- - New - -
-

- Endpoints whose exact response is known, checked for substitution β€” plus whether a - server reachable by literal IP is also reachable by name. -

-
- Check for interception - -
-
- - {/* Traceroute TRACERT Hop Map */} -
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" - > -
- -
-
-

- Edge Path Explorer -

- - New - -
-

- Real DNS, TCP, TLS and first-byte timings, the CDN edge that answered, and whether - HTTP/3 is reaching your network. -

-
- 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 - -
-
- - {/* Port Scanner */} -
setActiveTab('portscanner')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-
-

- Local Port Scanner -

- - BETA - -
-

- Probe host IPs, dev servers, or subnets for open TCP socket services using browser resources. -

-
- Launch Scanner - -
-
- - {/* GeoIP Geolocation Inspector */} -
setActiveTab('geoip')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-
-

- GeoIP & ISP Inspector -

- - New - -
-

- Inspect physical coordinates, country flags, ISP / Autonomous System (ASN), and proxy/VPN security status for any IP or domain. -

-
- Launch GeoIP Tool - -
-
- - {/* Speed Test */} -
setActiveTab('speedtest')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-

- Speed & Bandwidth Test -

-

- Measure real-time download/upload throughput, peak Mbps, and bufferbloat under load. -

-
- Launch Tool - -
-
- - {/* Ping & Jitter */} -
setActiveTab('ping')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-

- Ping & Jitter Monitor -

-

- Multi-sample HTTP latency benchmarks, packet loss calculations, and real-time interval graphs. -

-
- Launch Tool - -
-
- - {/* DoH DNS Resolver */} -
setActiveTab('dns')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-

- DoH DNS Resolver -

-

- Query A, AAAA, MX, TXT, and CNAME records directly using Cloudflare & Google DNS-over-HTTPS. -

-
- Launch Tool - -
-
- - {/* WebRTC Candidate Gatherer */} -
setActiveTab('webrtc')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-

- WebRTC STUN Analyzer -

-

- Inspect ICE candidates, public reflexive IPs, local interface candidates, and NAT type inference. -

-
- Launch Tool - -
-
- - {/* CIDR Subnet Calculator */} -
setActiveTab('cidr')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-

- CIDR Subnet Calculator -

-

- Calculate usable host ranges, broadcast addresses, wildcard masks, and binary representation. -

-
- Launch Tool - -
-
- - {/* MAC Address / OUI Vendor */} -
setActiveTab('mac')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-

- MAC / OUI Lookup -

-

- Identify hardware manufacturer from MAC addresses using local OUI database dictionary. -

-
- Launch Tool - -
-
- - {/* HTTP Reachability Probe */} -
setActiveTab('httpprobe')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-

- HTTP Probe & CORS Analyzer -

-

- Probe endpoint availability, inspect CORS header permissions, and measure server response latency. -

-
- Launch Tool - -
-
- - {/* WebSocket Tester */} -
setActiveTab('websocket')} - className="group bg-slate-900 border border-slate-800 hover:border-cyan-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-500/5 hover:-translate-y-0.5" - > -
- -
-

- WebSocket Latency Tester -

-

- Connect to WebSocket endpoints, measure handshake timing, and perform live message ping loops. -

-
- Launch Tool - -
-
- - {/* Data Export & ZIP Bundler */} -
setActiveTab('export')} - className="group bg-gradient-to-br from-emerald-950/30 via-slate-900 to-slate-900 border border-emerald-500/30 hover:border-emerald-400 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-xl hover:shadow-emerald-500/10 hover:-translate-y-0.5" - > -
- -
-
-

- Data Export & ZIP Bundler -

- - New - -
-

- Export diagnostic test data into individual CSV files per test type or download a bundled ZIP archive. -

-
- Open Export Page - -
-
-
+ +

{tool.title}

+

{tool.description}

- - {/* Recent Activity Log Stream */} -
-
-
- -

- Recent Local Storage Diagnostic History -

-
- +
+ {tool.fields?.map(field => ( +
+ + setValues({ ...values, [field.key]: event.target.value })} + className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-sm font-mono focus:outline-none focus:border-cyan-500" /> +
+ ))} +
+ + {tool.tab !== 'history' && tool.tab !== 'export' && }
+
+
+ ); +} - {history.length === 0 ? ( -
- No diagnostic history recorded yet. Run a speed test or ping query to save local logs! -
- ) : ( -
- {history.slice(0, 5).map((item) => ( -
-
- - {item.type} - -
-
{item.title}
-
{item.summary}
-
-
- -
-
- - {new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
-
- ))} -
- )} +export function Dashboard({ setActiveTab, onLaunch, history }: { setActiveTab: (tab: ToolTab) => void; onLaunch: (tab: ToolTab, values: Record) => void; history: HistoryItem[] }) { + return ( +
+
+ {TOOLS.map(tool => )}
- - setShowResponsibleModal(false)} - onConfirm={() => { - setShowResponsibleModal(false); - executeFullAudit(); - }} - /> +
+
+

Recent diagnostic history

+ +
+ {history.length === 0 ?

No saved diagnostics yet. Results are stored in this browser.

: ( +
    + {history.slice(0, 5).map(item =>
  • +
    {item.title}
    +

    {item.summary}

    +
  • )} +
+ )} +
); -}; +} diff --git a/src/components/DnsBenchmark.tsx b/src/components/DnsBenchmark.tsx index a1db6fc..16b0295 100644 --- a/src/components/DnsBenchmark.tsx +++ b/src/components/DnsBenchmark.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 { Timer, Play, @@ -50,6 +51,7 @@ import { saveHistoryItem, StorageFullError } from '../utils/storage'; */ interface DnsBenchmarkProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } @@ -129,7 +131,7 @@ const MetricCell: React.FC<{ row: ResolverBenchmark; metric: DnsMetricSummary }> ); -export const DnsBenchmark: React.FC = ({ onHistoryUpdate }) => { +export const DnsBenchmark: React.FC = ({ onHistoryUpdate, launch }) => { const [result, setResult] = useState(null); const [isRunning, setIsRunning] = useState(false); const [stage, setStage] = useState(''); @@ -193,6 +195,17 @@ export const DnsBenchmark: 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 run(); + }, 0); + return () => window.clearTimeout(timer); + }); + const sorted = useMemo(() => { if (result === null) return []; const rows = [...result.resolvers]; diff --git a/src/components/DnsResolver.tsx b/src/components/DnsResolver.tsx index 20cbd79..46efd42 100644 --- a/src/components/DnsResolver.tsx +++ b/src/components/DnsResolver.tsx @@ -1,18 +1,20 @@ -import React, { useState } from 'react'; +import type { ToolLaunch } from '../types'; +import React, { useState, useEffect, useRef } from 'react'; import { Globe, Search, RefreshCw, CheckCircle2, Copy, Code } from 'lucide-react'; import { DnsQueryResult, HistoryItem } from '../types'; import { queryDnsOverHttps } from '../utils/network'; import { saveHistoryItem } from '../utils/storage'; interface DnsResolverProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } const PRESET_DOMAINS = ['google.com', 'cloudflare.com', 'github.com', 'wikipedia.org', 'apple.com']; const RECORD_TYPES = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'CAA', 'SOA']; -export const DnsResolver: React.FC = ({ onHistoryUpdate }) => { - const [domain, setDomain] = useState('cloudflare.com'); +export const DnsResolver: React.FC = ({ onHistoryUpdate, launch }) => { + const [domain, setDomain] = useState(launch?.values.domain ?? 'cloudflare.com'); const [recordType, setRecordType] = useState('A'); const [provider, setProvider] = useState<'cloudflare' | 'google'>('cloudflare'); const [isQuerying, setIsQuerying] = useState(false); @@ -50,6 +52,17 @@ export const DnsResolver: 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 handleLookup(); + }, 0); + return () => window.clearTimeout(timer); + }); + const copyToClipboard = (text: string, index: number) => { navigator.clipboard.writeText(text); setCopiedIndex(index); @@ -71,24 +84,6 @@ export const DnsResolver: React.FC = ({ onHistoryUpdate }) => Query standard DNS records over encrypted HTTPS protocols directly from your browser using Cloudflare or Google DoH backends.

- -
{/* Query Form Bar */} @@ -154,6 +149,24 @@ export const DnsResolver: React.FC = ({ onHistoryUpdate }) =>
+ + {/* Query Results View */} diff --git a/src/components/DualStackCheck.tsx b/src/components/DualStackCheck.tsx index 7fd96a2..d87a7ad 100644 --- a/src/components/DualStackCheck.tsx +++ b/src/components/DualStackCheck.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import type { ToolLaunch } from '../types'; +import React, { useState, useEffect, useRef } from 'react'; import { Network, Play, Loader2, CheckCircle2, XCircle, MinusCircle } from 'lucide-react'; import type { AddressFamily, DualStackResult, HistoryItem } from '../types'; import { checkDualStack, describeReachability } from '../utils/dualStack'; @@ -16,6 +17,7 @@ import { saveHistoryItem } from '../utils/storage'; */ interface DualStackCheckProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } @@ -79,7 +81,7 @@ const FamilyCard: React.FC<{ ); }; -export const DualStackCheck: React.FC = ({ onHistoryUpdate }) => { +export const DualStackCheck: React.FC = ({ onHistoryUpdate, launch }) => { const [result, setResult] = useState(null); const [isRunning, setIsRunning] = useState(false); const [stage, setStage] = useState(''); @@ -109,6 +111,17 @@ export const DualStackCheck: 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 run(); + }, 0); + return () => window.clearTimeout(timer); + }); + return (
diff --git a/src/components/EdgeMap.tsx b/src/components/EdgeMap.tsx index a56d83f..9f2db0f 100644 --- a/src/components/EdgeMap.tsx +++ b/src/components/EdgeMap.tsx @@ -187,14 +187,14 @@ export const EdgeMap: React.FC = ({ bounds.push([node.lat, node.lng]); L.marker([node.lat, node.lng], { icon: L.divIcon({ - className: 'custom-tracert-marker', + className: 'custom-edge-marker', html: buildBadge(node), iconSize: [28, 28], iconAnchor: [14, 14], }), title: node.label, }) - .bindPopup(buildPopup(node), { className: 'tracert-dark-popup' }) + .bindPopup(buildPopup(node), { className: 'edge-dark-popup' }) .addTo(layer); } diff --git a/src/components/EdgePathExplorer.tsx b/src/components/EdgePathExplorer.tsx index a4c6733..e4fc35c 100644 --- a/src/components/EdgePathExplorer.tsx +++ b/src/components/EdgePathExplorer.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import type { ToolLaunch } from '../types'; +import React, { useMemo, useState, useEffect, useRef } from 'react'; import { Compass, Play, @@ -18,6 +19,7 @@ import { FailureNotice, displayMetric } from './MetricValue'; import { EdgeMap, type MapNode, type MapLink, type MapRing } from './EdgeMap'; interface EdgePathExplorerProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } @@ -83,8 +85,8 @@ const Waterfall: React.FC<{ probe: EdgeProbeResult }> = ({ probe }) => { ); }; -export const EdgePathExplorer: React.FC = ({ onHistoryUpdate }) => { - const [targetHost, setTargetHost] = useState('cloudflare.com'); +export const EdgePathExplorer: React.FC = ({ onHistoryUpdate, launch }) => { + const [targetHost, setTargetHost] = useState(launch?.values.target ?? 'cloudflare.com'); const [isRunning, setIsRunning] = useState(false); const [stage, setStage] = useState(''); const [result, setResult] = useState(null); @@ -139,6 +141,17 @@ export const EdgePathExplorer: React.FC = ({ onHistoryUpd 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 handleStart(); + }, 0); + return () => window.clearTimeout(timer); + }); + // Only points with genuine coordinates reach the map. const { nodes, links, rings } = useMemo(() => { const n: MapNode[] = []; @@ -252,24 +265,6 @@ export const EdgePathExplorer: React.FC = ({ onHistoryUpd negotiated, and how far away a server can possibly be.

- -
{/* Why there are no intermediate hops. */} @@ -333,6 +328,24 @@ export const EdgePathExplorer: React.FC = ({ onHistoryUpd the field is reported as unavailable rather than guessed β€” the rest of the exploration still runs.

+ + {result && ( @@ -436,10 +449,10 @@ export const EdgePathExplorer: React.FC = ({ onHistoryUpd

{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'} + 'HTTP/3 was not observed'} + {result.protocolEvidence.verdict === 'legacy-http1' && 'Only HTTP/1.x was observed'} {result.protocolEvidence.verdict === 'http2-only' && 'HTTP/2 in use'} - {result.protocolEvidence.verdict === null && 'Protocol not observable'} + {result.protocolEvidence.verdict === null && 'Protocol evidence is unavailable or inconclusive'}

{result.protocolEvidence.explanation} diff --git a/src/components/ExportPage.tsx b/src/components/ExportPage.tsx index 0d9101f..e4956b5 100644 --- a/src/components/ExportPage.tsx +++ b/src/components/ExportPage.tsx @@ -12,7 +12,6 @@ import { RefreshCw, Copy, Check, - GitCommit, Gauge, Radio, Radar, @@ -68,8 +67,6 @@ export const ExportPage: React.FC = ({ onHistoryUpdate }) => { // Icon Map Helper const renderIcon = (typeId: string, className: string = 'w-5 h-5') => { switch (typeId) { - case 'tracert': - return ; case 'speedtest': return ; case 'ping': @@ -225,7 +222,7 @@ export const ExportPage: React.FC = ({ onHistoryUpdate }) => {

- Export diagnostic reports for Traceroute, Speed Tests, Port Scans, DNS, and WebRTC. Download individual CSV files per test type or package everything into a single, bundled ZIP archive. + Export diagnostic reports for Speed Tests, Port Scans, DNS, and WebRTC. Download individual CSV files per test type or package everything into a single, bundled ZIP archive.

diff --git a/src/components/GeoIpLookup.tsx b/src/components/GeoIpLookup.tsx index 42a9640..af80bdb 100644 --- a/src/components/GeoIpLookup.tsx +++ b/src/components/GeoIpLookup.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import type { ToolLaunch } from '../types'; +import React, { useState, useEffect, useRef } from 'react'; import { MapPin, Search, @@ -7,7 +8,6 @@ import { ExternalLink, Copy, Check, - RefreshCw, Navigation, Server, Radio, @@ -18,16 +18,45 @@ import { GeoIpResult } from '../types'; import { saveHistoryItem } from '../utils/storage'; interface GeoIpLookupProps { + launch?: ToolLaunch; onHistoryUpdate?: () => void; } -export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => { - const [inputQuery, setInputQuery] = useState(''); +interface GeoApiResponse { + ip?: string; + success?: boolean; + error?: boolean; + city?: string; + region?: string; + country?: string; + country_name?: string; + country_code?: string; + flag?: { emoji?: string }; + postal?: string; + latitude?: number | null; + longitude?: number | null; + timezone?: string | { id?: string; utc?: string }; + connection?: { isp?: string; org?: string; asn?: number }; + security?: { proxy?: boolean | null; vpn?: boolean | null }; + org?: string; + asn?: string; + calling_code?: string | number; + country_calling_code?: string; + currency?: string | { code?: string; symbol?: string }; +} + +// Missing, invalid, and out-of-range coordinates cannot place a map marker. +const coordinate = (value: unknown, limit: number): number | null => + typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= limit ? value : null; + +export const GeoIpLookup: React.FC = ({ onHistoryUpdate, launch }) => { + const [inputQuery, setInputQuery] = useState(launch?.values.target ?? ''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); - const [activeQuickTab, setActiveQuickTab] = useState('myip'); + const [activeQuickTab, setActiveQuickTab] = useState(launch?.values.target ? '' : 'myip'); + const latestLookupRef = useRef(0); // Helper to resolve domain to IP via DNS over HTTPS if domain entered const resolveDomainToIp = async (query: string): Promise => { @@ -44,9 +73,9 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => try { const res = await fetch(`https://dns.google/resolve?name=${encodeURIComponent(clean)}&type=A`); if (res.ok) { - const data = await res.json(); + const data = await res.json() as { Answer?: { type: number; data: string }[] }; if (data.Answer && data.Answer.length > 0) { - const aRecord = data.Answer.find((ans: any) => ans.type === 1); + const aRecord = data.Answer.find((ans) => ans.type === 1); if (aRecord && aRecord.data) { return aRecord.data; } @@ -59,8 +88,10 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => }; const performLookup = async (targetIpOrDomain?: string) => { + const lookupId = ++latestLookupRef.current; setLoading(true); setError(null); + setResult(null); const rawQuery = targetIpOrDomain !== undefined ? targetIpOrDomain : inputQuery; const cleanQuery = rawQuery.trim(); @@ -72,7 +103,7 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => } // Try Primary Geolocation API: ipwho.is (Supports HTTPS & CORS) - let geoData: any = null; + let geoData: Omit | null = null; let apiSuccess = false; const primaryUrl = targetIp @@ -82,8 +113,8 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => try { const response = await fetch(primaryUrl); if (response.ok) { - const json = await response.json(); - if (json && json.success !== false) { + const json = await response.json() as GeoApiResponse; + if (json?.ip && json.success !== false) { geoData = { ip: json.ip, city: json.city || 'Unknown City', @@ -92,16 +123,19 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => countryCode: json.country_code || 'UN', flag: json.flag?.emoji || '🌐', postal: json.postal || 'N/A', - lat: json.latitude || 0, - lng: json.longitude || 0, - timezone: json.timezone?.id ? `${json.timezone.id} (${json.timezone.utc || ''})` : 'UTC', + lat: coordinate(json.latitude, 90), + lng: coordinate(json.longitude, 180), + timezone: typeof json.timezone === 'object' && json.timezone?.id + ? `${json.timezone.id}${json.timezone.utc ? ` (${json.timezone.utc})` : ''}` + : undefined, isp: json.connection?.isp || json.connection?.org || 'Unknown ISP', org: json.connection?.org || json.connection?.isp || '', asn: json.connection?.asn ? `AS${json.connection.asn}` : '', callingCode: json.calling_code ? `+${json.calling_code}` : '', - currency: json.currency ? `${json.currency.code} (${json.currency.symbol || ''})` : '', - isProxy: json.security?.proxy || false, - isVpn: json.security?.vpn || false, + currency: typeof json.currency === 'object' && json.currency + ? `${json.currency.code ?? ''} (${json.currency.symbol ?? ''})` : '', + isProxy: typeof json.security?.proxy === 'boolean' ? json.security.proxy : null, + isVpn: typeof json.security?.vpn === 'boolean' ? json.security.vpn : null, }; apiSuccess = true; } @@ -119,8 +153,8 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => const fallbackRes = await fetch(fallbackUrl); if (fallbackRes.ok) { - const json = await fallbackRes.json(); - if (json && !json.error) { + const json = await fallbackRes.json() as GeoApiResponse; + if (json?.ip && !json.error) { geoData = { ip: json.ip, city: json.city || 'Unknown City', @@ -129,16 +163,16 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => countryCode: json.country_code || 'UN', flag: '🌐', postal: json.postal || 'N/A', - lat: json.latitude || 0, - lng: json.longitude || 0, - timezone: json.timezone || 'UTC', + lat: coordinate(json.latitude, 90), + lng: coordinate(json.longitude, 180), + timezone: typeof json.timezone === 'string' ? json.timezone : undefined, isp: json.org || json.asn || 'Unknown ISP', org: json.org || '', asn: json.asn || '', callingCode: json.country_calling_code || '', - currency: json.currency || '', - isProxy: false, - isVpn: false, + currency: typeof json.currency === 'string' ? json.currency : '', + isProxy: null, + isVpn: null, }; apiSuccess = true; } @@ -159,6 +193,7 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => ...geoData, }; + if (lookupId !== latestLookupRef.current) return; setResult(lookupResult); // Save to global storage history @@ -174,17 +209,26 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => if (onHistoryUpdate) { onHistoryUpdate(); } - } catch (err: any) { - setError(err.message || 'An error occurred during GeoIP lookup.'); + } catch (err) { + if (lookupId === latestLookupRef.current) { + setError(err instanceof Error ? err.message : 'An error occurred during GeoIP lookup.'); + } } finally { - setLoading(false); + if (lookupId === latestLookupRef.current) setLoading(false); } }; - // Perform initial lookup on mount for user's own public IP + const launchedIdRef = useRef(null); useEffect(() => { - performLookup(''); - }, []); + if (launch === undefined) return; + const launchId = launch.id; + if (launchedIdRef.current === launchId) return; + const timer = window.setTimeout(() => { + launchedIdRef.current = launchId; + void performLookup(launch?.values.target ?? ''); + }, 0); + return () => window.clearTimeout(timer); + }); const handlePresetSelect = (ip: string, tabName: string) => { setActiveQuickTab(tabName); @@ -194,7 +238,7 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => const handleCopy = () => { if (!result) return; - const text = `IP: ${result.ip}\nLocation: ${result.city}, ${result.region}, ${result.country}\nCoordinates: ${result.lat}, ${result.lng}\nISP: ${result.isp}\nASN: ${result.asn || 'N/A'}`; + const text = `IP: ${result.ip}\nLocation: ${result.city}, ${result.region}, ${result.country}\nCoordinates: ${result.lat ?? 'unavailable'}, ${result.lng ?? 'unavailable'}\nISP: ${result.isp}\nASN: ${result.asn || 'N/A'}`; navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); @@ -221,15 +265,6 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => Inspect physical geolocation coordinates, Autonomous System Number (ASN), Internet Service Provider (ISP), timezone, and proxy/VPN security status for any IPv4/IPv6 address or domain.

- - {/* Input Controls & Quick Targets */} @@ -257,7 +292,7 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => disabled={loading} className="w-full sm:w-auto bg-slate-800 hover:bg-slate-700 text-slate-100 border border-slate-700 font-semibold text-xs px-5 py-3 rounded-xl transition-all cursor-pointer shrink-0" > - Lookup Address + {loading ? 'Querying...' : 'Lookup Address'} @@ -410,8 +445,11 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => Coordinates - {result.lat.toFixed(4)}, {result.lng.toFixed(4)} + {result.lat?.toFixed(4) ?? 'β€”'}, {result.lng?.toFixed(4) ?? 'β€”'} + {(result.lat === null || result.lng === null) && ( +

The geolocation provider did not return coordinates.

+ )} {/* Timezone */} @@ -420,8 +458,11 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => Timezone - {result.timezone || 'UTC'} + {result.timezone || 'β€”'} + {!result.timezone && ( +

The geolocation provider did not return a timezone.

+ )} {/* Calling Code */} @@ -492,12 +533,14 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => Proxy Detected - {result.isProxy ? 'YES' : 'NO'} + {result.isProxy == null ? 'β€”' : result.isProxy ? 'YES' : 'NO'} @@ -505,12 +548,14 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => VPN Node - {result.isVpn ? 'YES' : 'NO'} + {result.isVpn == null ? 'β€”' : result.isVpn ? 'YES' : 'NO'} @@ -521,11 +566,15 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => + {(result.isProxy == null || result.isVpn == null) && ( +

Unavailable security signals were not returned by the geolocation provider.

+ )} {/* Right Column: Geographic Map Coordinates & External Link (1 Col) */}
+ {result.lat !== null && result.lng !== null ? (
@@ -566,7 +615,7 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) => {/* Overlay Badge */}
Lat: {result.lat.toFixed(4)}, Lng: {result.lng.toFixed(4)} - Verified Location + Provider estimate
@@ -583,6 +632,11 @@ export const GeoIpLookup: React.FC = ({ onHistoryUpdate }) =>
+ ) : ( +
+ Map unavailable: the geolocation provider did not return coordinates. +
+ )} {/* Documentation Note */}
diff --git a/src/components/HistoryLog.tsx b/src/components/HistoryLog.tsx index ba98a96..e08ec88 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', 'edgepath', 'tracert', 'portscanner', 'geoip', 'speedtest', 'ping', 'dns', 'webrtc', 'cidr', 'mac', 'httpprobe', 'websocket'].map((t) => ( + {['all', 'edgepath', 'portscanner', 'geoip', 'speedtest', 'ping', 'dns', 'webrtc', 'cidr', 'mac', 'httpprobe', 'websocket'].map((t) => (
{/* Input Form */} @@ -133,6 +128,24 @@ export const HttpProbe: React.FC = ({ onHistoryUpdate }) => { 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-indigo-500" />
+ +
{/* Results Box */} diff --git a/src/components/MacLookup.tsx b/src/components/MacLookup.tsx index 0d0fa36..04f1c6d 100644 --- a/src/components/MacLookup.tsx +++ b/src/components/MacLookup.tsx @@ -1,3 +1,4 @@ +import type { ToolLaunch } from '../types'; import React, { useState } from 'react'; import { Search, Copy, CheckCircle2, Bookmark } from 'lucide-react'; import { parseAndLookupMac } from '../utils/oui'; @@ -5,6 +6,7 @@ import { HistoryItem } from '../types'; import { saveHistoryItem } from '../utils/storage'; interface MacLookupProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } @@ -17,8 +19,8 @@ const SAMPLE_MACS = [ { label: 'Ubiquiti AP', mac: '80:2A:A8:11:22:33' }, ]; -export const MacLookup: React.FC = ({ onHistoryUpdate }) => { - const [inputMac, setInputMac] = useState('DC:A6:32:00:11:22'); +export const MacLookup: React.FC = ({ onHistoryUpdate, launch }) => { + const [inputMac, setInputMac] = useState(launch?.values.mac ?? 'DC:A6:32:00:11:22'); const [copied, setCopied] = useState(false); const lookupResult = parseAndLookupMac(inputMac); @@ -58,16 +60,6 @@ export const MacLookup: React.FC = ({ onHistoryUpdate }) => { Offline hardware vendor detection engine using IEEE Organizationally Unique Identifier (OUI) dictionary parsing.

- - {lookupResult.isKnown && ( - - )} {/* Input Form */} @@ -98,6 +90,16 @@ export const MacLookup: React.FC = ({ onHistoryUpdate }) => { className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-base text-slate-100 font-mono focus:outline-none focus:border-rose-500" /> + + {lookupResult.isKnown && ( + + )} {/* Result Display */} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ade60b0..842cccb 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -16,7 +16,6 @@ import { Radar, ShieldCheck, Heart, - GitCommit, Archive, AlertTriangle, Compass, @@ -61,7 +60,6 @@ export const Navbar: React.FC = ({ { id: 'dualstack', label: 'IPv4 / IPv6', icon: Network, badge: 'NEW' }, { id: 'captive', label: 'Portal & DNS Hijack', icon: ShieldQuestion, badge: 'NEW' }, { id: 'edgepath', label: 'Edge Path', icon: Compass }, - { id: 'tracert', label: 'Route Model', icon: GitCommit, badge: 'SIM' }, { id: 'portscanner', label: 'Port Scanner', icon: Radar, badge: 'BETA' }, { id: 'geoip', label: 'GeoIP Lookup', icon: Globe }, { id: 'speedtest', label: 'Speed Test', icon: Gauge }, diff --git a/src/components/PingTester.tsx b/src/components/PingTester.tsx index 2cfe7c8..770fd76 100644 --- a/src/components/PingTester.tsx +++ b/src/components/PingTester.tsx @@ -1,3 +1,4 @@ +import type { ToolLaunch } from '../types'; import React, { useState, useRef, useEffect } from 'react'; import { Radio, Play, StopCircle, AlertCircle, CheckCircle2 } from 'lucide-react'; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'; @@ -8,12 +9,13 @@ import { ResponsibleNetworkingModal, isResponsibleNetworkingAccepted } from './R import { MetricValue, displayMetric } from './MetricValue'; interface PingTesterProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } -export const PingTester: React.FC = ({ onHistoryUpdate }) => { +export const PingTester: React.FC = ({ onHistoryUpdate, launch }) => { const [selectedTarget, setSelectedTarget] = useState(PING_TARGET_PRESETS[0].url); - const [customTarget, setCustomTarget] = useState(''); + const [customTarget, setCustomTarget] = useState(launch?.values.target ?? ''); const [packetCount, setPacketCount] = useState(15); const [isPinging, setIsPinging] = useState(false); const [points, setPoints] = useState([]); @@ -138,6 +140,17 @@ export const PingTester: React.FC = ({ onHistoryUpdate }) => { executePing(); }; + 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 handleStartPing(); + }, 0); + return () => window.clearTimeout(timer); + }); + return (
{/* Header Banner */} @@ -153,29 +166,6 @@ export const PingTester: React.FC = ({ onHistoryUpdate }) => { Measures round-trip time, jitter and packet loss using HTTP HEAD probes. This is application-layer timing, not ICMP β€” it includes TLS and server handling, so it reads slightly higher than a system ping.

- - {/* Target Selector Bar */} @@ -244,6 +234,29 @@ export const PingTester: React.FC = ({ onHistoryUpdate }) => { + + {/* Ping Metrics Summary Cards */} @@ -351,7 +364,7 @@ export const PingTester: React.FC = ({ onHistoryUpdate }) => { {pt.status === 'success' ? ( - 200 OK + Answered ) : ( diff --git a/src/components/PortScanner.tsx b/src/components/PortScanner.tsx index 8ff88b7..265b2ac 100644 --- a/src/components/PortScanner.tsx +++ b/src/components/PortScanner.tsx @@ -1,4 +1,5 @@ -import React, { useState, useMemo } from 'react'; +import type { ToolLaunch } from '../types'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { Radar, Play, @@ -16,9 +17,12 @@ import { import { PortScanResult, PortStatus, HistoryItem } from '../types'; import { scanPortList, gatherWebRtcCandidates, describeTargetExpansion } from '../utils/network'; import { saveHistoryItem } from '../utils/storage'; +import { MAX_SCAN_PORTS, parsePortSelection } from '../utils/ports'; +import { MetricValue } from './MetricValue'; import { ResponsibleNetworkingModal, isResponsibleNetworkingAccepted } from './ResponsibleNetworkingModal'; interface PortScannerProps { + launch?: ToolLaunch; onHistoryUpdate: () => void; } @@ -41,11 +45,11 @@ const PORT_PRESETS = [ }, ]; -export const PortScanner: React.FC = ({ onHistoryUpdate }) => { - const [targetHost, setTargetHost] = useState('127.0.0.1'); +export const PortScanner: React.FC = ({ onHistoryUpdate, launch }) => { + const [targetHost, setTargetHost] = useState(launch?.values.target ?? '127.0.0.1'); const [selectedPreset, setSelectedPreset] = useState('Web & Development'); - const [customPortInput, setCustomPortInput] = useState('80, 443, 3000-3005, 8080'); - const [portSelectionMode, setPortSelectionMode] = useState<'preset' | 'custom'>('preset'); + const [customPortInput, setCustomPortInput] = useState(launch?.values.ports ?? '80, 443, 3000-3005, 8080'); + const [portSelectionMode, setPortSelectionMode] = useState<'preset' | 'custom'>(launch?.values.ports ? 'custom' : 'preset'); const [showBetaDetails, setShowBetaDetails] = useState(true); const [isScanning, setIsScanning] = useState(false); @@ -61,47 +65,27 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => phase: 'scanning', }); const [activeResult, setActiveResult] = useState(null); - const [filterStatus, setFilterStatus] = useState<'all' | 'open' | 'closed' | 'filtered'>('all'); - const [hideOfflineHosts, setHideOfflineHosts] = useState(true); + const [filterStatus, setFilterStatus] = useState<'all' | PortStatus['status']>('all'); + const [hideUnresponsiveHosts, setHideUnresponsiveHosts] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [detectingSubnet, setDetectingSubnet] = useState(false); const [detectedSubnetInfo, setDetectedSubnetInfo] = useState(null); const truncationWarning = useMemo(() => describeTargetExpansion(targetHost), [targetHost]); const [showResponsibleModal, setShowResponsibleModal] = useState(false); + const [scanError, setScanError] = useState(null); - // Helper to parse comma separated ports or ranges e.g. "80, 443, 3000-3010" - const parsePortsToScan = (): number[] => { - if (portSelectionMode === 'preset') { - const p = PORT_PRESETS.find((item) => item.name === selectedPreset); - return p ? p.ports : [80, 443, 3000, 8080]; - } - - const portsSet = new Set(); - const rawParts = customPortInput.split(','); - - for (const raw of rawParts) { - const part = raw.trim(); - if (part.includes('-')) { - const [startStr, endStr] = part.split('-'); - const start = parseInt(startStr, 10); - const end = parseInt(endStr, 10); - if (!isNaN(start) && !isNaN(end) && start > 0 && end <= 65535 && start <= end) { - const count = Math.min(100, end - start + 1); - for (let i = 0; i < count; i++) { - portsSet.add(start + i); - } - } - } else { - const num = parseInt(part, 10); - if (!isNaN(num) && num > 0 && num <= 65535) { - portsSet.add(num); - } + const portSelection = useMemo(() => { + try { + if (portSelectionMode === 'preset') { + const preset = PORT_PRESETS.find(item => item.name === selectedPreset); + if (!preset) throw new Error('Choose a port preset.'); + return { ports: preset.ports, error: null }; } + return { ports: parsePortSelection(customPortInput), error: null }; + } catch (error) { + return { ports: [], error: error instanceof Error ? error.message : 'Invalid port selection.' }; } - - const list = Array.from(portsSet).sort((a, b) => a - b); - return list.length > 0 ? list : [80, 443, 3000, 8080]; - }; + }, [portSelectionMode, selectedPreset, customPortInput]); const handleAutoDetectSubnet = async () => { setDetectingSubnet(true); @@ -140,12 +124,17 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => }; const executeScan = async () => { - const portsToScan = parsePortsToScan(); - if (!targetHost.trim() || portsToScan.length === 0) return; + if (portSelection.error) return; + if (!targetHost.trim()) { + setScanError('Enter a target host, subnet, or IP range.'); + return; + } + const portsToScan = portSelection.ports; + setScanError(null); setIsScanning(true); setActiveResult(null); - setScanProgress({ scanned: 0, total: 100 }); + setScanProgress({ scanned: 0, total: 0 }); try { const res = await scanPortList(targetHost, portsToScan, (scanned, total, last, phase) => { @@ -166,7 +155,7 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => type: 'portscanner', timestamp: res.timestamp, title: `Port Scan: ${res.targetHost}`, - summary: `Hosts: ${res.scannedHosts.length} | Open Ports: ${res.openPortsCount} | Closed: ${res.closedPortsCount} | Filtered: ${res.filteredPortsCount} (${res.scanDurationMs}ms)`, + summary: `Host targets: ${res.scannedHosts.length} | Open (heuristic): ${res.openPortsCount} | Closed: ${res.closedPortsCount} | Filtered: ${res.filteredPortsCount} | Not tested: ${res.ports.filter(p => p.status === 'not-tested').length} (${res.scanDurationMs}ms)`, data: res, }; @@ -174,12 +163,19 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => onHistoryUpdate(); } catch (e) { console.error('Port scan error:', e); + setScanError(e instanceof Error ? `The scan could not finish: ${e.message}` : 'The scan could not finish.'); } finally { setIsScanning(false); } }; const handleStartScan = () => { + if (portSelection.error) return; + if (!targetHost.trim()) { + setScanError('Enter a target host, subnet, or IP range.'); + return; + } + setScanError(null); if (!isResponsibleNetworkingAccepted()) { setShowResponsibleModal(true); return; @@ -187,8 +183,19 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => executeScan(); }; - // Calculate sets of totally offline/unresponsive hosts (where 100% of probed ports are 'filtered') - const offlineHostsSet = useMemo(() => { + 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 handleStartScan(); + }, 0); + return () => window.clearTimeout(timer); + }); + + // Unanswered probes do not prove a host is offline; never hide untested entries. + const unresponsiveHostsSet = useMemo(() => { if (!activeResult) return new Set(); const hostStatusMap: Record = {}; for (const p of activeResult.ports) { @@ -211,12 +218,12 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => const filteredPorts = activeResult ? activeResult.ports.filter((p) => { - // If hideOfflineHosts is enabled and scanning multiple hosts, hide totally unresponsive hosts unless viewing filtered specifically + // A user may hide unanswered probes, but untested entries remain visible. if ( - hideOfflineHosts && - filterStatus !== 'filtered' && + hideUnresponsiveHosts && + filterStatus === 'all' && activeResult.scannedHosts.length > 1 && - offlineHostsSet.has(p.host) + unresponsiveHostsSet.has(p.host) ) { return false; } @@ -271,24 +278,6 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => Scan host IPs, local dev servers, or entire CIDR subnets (e.g. 192.168.1.0/24) for open TCP/Web services directly within your browser, complete with host IP identification and direct HTTP/HTTPS hyperlinks.

- - {/* BETA Security & Certificate Notice Banner */} @@ -386,7 +375,7 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => type="text" placeholder="e.g. 192.168.1.0/24, 10.0.0.1-20, 127.0.0.1, or scanme.nmap.org" value={targetHost} - onChange={(e) => setTargetHost(e.target.value)} + onChange={(e) => { setTargetHost(e.target.value); setScanError(null); }} 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" /> @@ -492,16 +481,40 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => setCustomPortInput(e.target.value)} + onChange={(e) => { setCustomPortInput(e.target.value); setScanError(null); }} placeholder="e.g. 80, 443, 3000-3010, 8080" 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" />

- Enter comma-separated ports or range pairs (e.g., 80, 443, 3000-3010). + Enter comma-separated ports or ranges (e.g., 80, 443, 3000-3010). Maximum {MAX_SCAN_PORTS} distinct ports; larger selections are rejected.

)} + + {(portSelection.error || scanError) && ( +

+ {portSelection.error ?? scanError} +

+ )} + + {/* Live Scan Progress Bar */} @@ -554,18 +567,18 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => Target: {activeResult.targetHost}
- Scanned {activeResult.scannedHosts.length} host IP(s) across {activeResult.scannedPorts.length} port(s) in {activeResult.scanDurationMs} ms • {new Date(activeResult.timestamp).toLocaleString()} + Processed {activeResult.scannedHosts.length} host target(s) with {activeResult.scannedPorts.length} requested port(s) in {activeResult.scanDurationMs} ms • {new Date(activeResult.timestamp).toLocaleString()}
-
+
-
Active Devices
+
Hosts answered
{activeResult.discoveredHostsCount}
-
Open Ports
+
Open (heuristic)
{activeResult.openPortsCount}
@@ -578,9 +591,18 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) =>
Filtered Ports
{activeResult.filteredPortsCount}
+
+
Not tested
+
{activeResult.ports.filter(p => p.status === 'not-tested').length}
+
+

+ Open, closed, and filtered are browser timing heuristics, not confirmed TCP port states. + Not tested means no probe ran. β€” means no latency was measured. +

+ {/* Discovered Subnet Devices Summary Card */} {openDevicesList.length > 0 && (
@@ -641,7 +663,7 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) =>
- {(['all', 'open', 'closed', 'filtered'] as const).map((status) => ( + {(['all', 'open', 'closed', 'filtered', 'not-tested'] as const).map((status) => ( ))}
{activeResult.scannedHosts.length > 1 && ( )}
@@ -693,7 +715,7 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => Port Status Service Name - Latency RTT + Probe latency Web Service Link Description @@ -741,10 +763,12 @@ export const PortScanner: React.FC = ({ onHistoryUpdate }) => ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30' : p.status === 'closed' ? 'bg-slate-800 text-slate-400 border border-slate-700' - : 'bg-amber-500/20 text-amber-400 border border-amber-500/30' + : p.status === 'filtered' + ? 'bg-amber-500/20 text-amber-400 border border-amber-500/30' + : 'bg-slate-800 text-slate-400 border border-slate-700' }`} > - {p.status} + {p.status === 'not-tested' ? 'Not tested' : p.status} @@ -752,7 +776,14 @@ export const PortScanner: React.FC = ({ 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 */} -
- - - - - -
- - {/* Auto-Follow Hop Toggle */} - - - {/* Fit Bounds */} - - - {/* View Mode Switcher */} -
- - -
-
-
- - {/* 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. -

-
- - -
- - {/* Target & Protocol Controls Bar */} -
-

- Target Endpoint & Preset Selection -

- - {/* Preset Chips */} -
- {TRACERT_PRESETS.map((p) => { - const isSelected = selectedPreset === p.target && !customTarget; - return ( - - ); - })} -
- - {/* Custom Input & Protocol Selectors */} -
-
- - 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" - /> -
- -
- - -
- -
- - -
-
-
- - {/* 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! -
- ) : ( - - - - - - - - - - - - - {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' - }`} - > - - - - - - - - - - - - - ); - })} - -
HopNode / HostnameIP AddressRTT AvgLocationISP / ASN
- - {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 -

-
- - -
- -
-
{getCliText()}
-
-
- -
- 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 && ( +
+ + 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 ? (
{/* 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" /> + + {/* 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.

- - {/* 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" /> + + + {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, - }; -}