diff --git a/README.md b/README.md index d37f2df..1f20d02 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,10 @@ by design and would manufacture findings out of geography. ### 🚢 Walk & Test β€” *survey the building, not just the connection* -Ten destinations people actually depend on β€” Google, YouTube, Netflix, Facebook and Amazon on the -consumer side; Microsoft 365, Teams, Zoom, Salesforce and Slack on the business side β€” probed round -after round while you walk the building. Name the spot you are standing in, wait for a few rounds, +Ten destinations people actually depend on β€” Google, YouTube, Netflix and Amazon on the consumer +side; Microsoft 365, Teams, Zoom, Salesforce, Slack and Atlassian on the business side β€” probed +round after round while you walk the building. Each gets a card with its own sparkline, and all the +sparklines share one scale so the shapes are comparable at a glance. Name the spot you are standing in, wait for a few rounds, move, name the next one. The result is a **per-spot comparison**: which corner of the office loses Teams, where the round trips double, which dead spot is a dead spot for everything and which is only bad for one destination. @@ -111,12 +112,18 @@ What the numbers are, stated permanently on screen rather than in a footnote: - **These are front doors, not backends.** Netflix playback, Teams call audio and Zoom media run over paths a browser cannot address, so a green row does not promise a smooth call. - **Unanswered is not "down", and it is not packet loss.** A timeout, a refused connection, a failed - lookup and being out of range look identical to a browser. The column is called *Answered*. + lookup and being out of range look identical to a browser. So does a blocklist: an ad blocker or a + filtering resolver makes a healthy network look like a dead spot for one destination. The counter + is called *Answered* for that reason, and it is why Meta domains are deliberately not in the list. - **All ten fire at once each round**, which gives them the same instant β€” and makes them compete on a constrained link. Compare rows and spots to each other, not a single figure to a spec sheet. -Each destination's first probe pays for DNS, TCP and TLS, so it is counted but kept out of the -timing statistics. Spots are compared by the **median of each destination's own median**, over only +The opening two rounds and each destination's first answer pay for DNS, TCP and TLS, so they are +counted but kept out of every timing statistic and every chart. Left in, they pinned the top of the +y-axis near a second for the rest of the session and flattened every real measurement into a line +along the bottom. Rounds fire every 1, 2, 3, 5 or 10 seconds; the interval is the pause *between* +rounds, and a round waits for all ten destinations to answer or time out first, so a struggling link +stretches the gap rather than piling requests up. Spots are compared by the **median of each destination's own median**, over only the destinations that produced a median at *every* spot β€” pooling raw samples instead would make the figure lurch when a destination dropped out, reporting a change in which destinations answered as though it were a change in latency. @@ -197,10 +204,7 @@ Handshake timing and application-layer echo round-trips over `ws://` and `wss:// ### 10. 🧭 GeoIP & ISP Inspector Geolocation, ISP, ASN and proxy/VPN signals for an IP or domain, via third-party lookup providers. -### 11. πŸ“Š Live Traffic Monitor -Real-time throughput and latency from the browser's own Performance Timeline. - -### 12. 🧭 Edge Path Explorer +### 11. 🧭 Edge Path Explorer Everything a browser can genuinely observe about the path to a host: - **Connection phase breakdown** β€” real DNS β†’ TCP β†’ TLS β†’ time-to-first-byte β†’ transfer timings @@ -215,7 +219,7 @@ Everything a browser can genuinely observe about the path to a host: how far away a server can be. Drawn as a constraint circle: the endpoint is somewhere inside it. This is a proof, not an estimate β€” queuing delay only loosens the bound. -### 13. πŸ—ΊοΈ Route Model *(simulated β€” read this)* +### 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 @@ -223,7 +227,7 @@ IP TTL, so no web page can perform a real traceroute. The first and last hops ar 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. -### 14. πŸ’Ύ History & Export +### 13. πŸ’Ύ History & Export Results persist in `localStorage`. Search, filter, inspect raw JSON, and export per-tool CSVs, a master summary, or a bundled ZIP with a manifest. @@ -268,9 +272,9 @@ without touching it, so the following go directly from your browser to third par | `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` | -| `www.google.com`, `www.youtube.com`, `www.netflix.com`, `www.facebook.com`, `www.amazon.com`, `outlook.office365.com`, `teams.microsoft.com`, `zoom.us`, `login.salesforce.com`, `slack.com` | Your IP, once per round for the length of a Walk & Test run β€” roughly 200 requests each over ten minutes. One HEAD for a small public file, no cookies sent | +| `www.google.com`, `www.youtube.com`, `www.netflix.com`, `www.amazon.com`, `outlook.office365.com`, `teams.microsoft.com`, `zoom.us`, `login.salesforce.com`, `slack.com`, `www.atlassian.com` | Your IP, once per round for the length of a Walk & Test run β€” up to ~600 requests each over ten minutes at the one-second interval. One HEAD for a small public file, no cookies sent | +| `api.github.com`, `httpbin.org`, `cloudflare.com`, `dns.google` | Your IP, if you run the HTTP probe against one of its one-click sample targets | | `stun.l.google.com` and other STUN servers | Your public IP, and potentially local addresses | -| `httpbin.org` | Your IP, only when you press "Trigger Network Spike" on the live traffic monitor | | `basemaps.cartocdn.com`, `openstreetmap.org` | Map areas you view, revealing an approximate target location | | Hosts you enter | Direct connections from your browser β€” that is what a probe *is* | diff --git a/docs/REMOVED-TRAFFIC-MONITOR.md b/docs/REMOVED-TRAFFIC-MONITOR.md new file mode 100644 index 0000000..0793b8a --- /dev/null +++ b/docs/REMOVED-TRAFFIC-MONITOR.md @@ -0,0 +1,80 @@ +# Removed: Live Traffic Monitor + +Removed from the dashboard on 23 August 2026. This file is the reference record, so +nobody has to go digging through history to find out what it was or why it went. + +The code is in git. Last commit that contained it: + +``` +git show 77305e2:src/components/TrafficMonitor.tsx +``` + +## What it was + +A panel embedded in the dashboard, below the readiness score, that watched the browser's +own resource timeline and charted it live. It contacted nothing on its own except when a +button was pressed. + +**How it worked.** A `PerformanceObserver` subscribed to `resource` entries, so every +request the page made for any reason β€” a speed test transfer, a DNS-over-HTTPS query, a +map tile, a favicon β€” landed in a buffer as it completed. On a one-second tick the buffer +was drained and aggregated into a `TrafficSample`, and the last 30 samples were kept as a +sliding window. + +**What it showed.** + +| Element | Content | +|---|---| +| Four KPI tiles | Throughput (Kbps), average latency over the last 5s, requests captured this session, total bytes transferred | +| Throughput sparkline | Kbps per second, 30-second window, cyan area chart | +| Latency sparkline | Average and peak ms per second, 30-second window, two series | +| Resource table | The most recent requests: name, initiator type, duration, transfer size | +| Filter chips | Narrowed the table by initiator type (fetch, img, script, …) | +| Pause / resume | Stopped the aggregation tick, not just the rendering | +| Trigger Network Spike | Made a handful of requests to `httpbin.org` so the sparklines had something real to draw on an idle page | +| Clear | Emptied the buffer and the window | + +**What it measured honestly.** Everything it drew came from real Resource Timing entries. +`duration` is readable cross-origin without `Timing-Allow-Origin`, so the latency figures +were genuine even for opaque responses β€” unlike the phase breakdown, which is not +readable and which this panel never claimed to show. `transferSize` is zeroed for opaque +responses, so the byte totals undercounted cross-origin traffic and the panel did not +say so. That was its one soft edge. + +**A bug it once had, worth remembering.** The average latency tile rendered `0 ms` when +no request had completed in the window, because the mean was computed with a `|| 1` +denominator dividing a sum of zeros. That is the exact failure mode `CLAUDE.md` exists to +prevent, and it was caught by an offline browser run rather than by review. Fixed at the +time to render `β€”` with a reason. + +## Why it was removed + +It was passive. It observed whatever traffic the page happened to generate, which meant +its numbers described NetReady's own activity rather than the network. On an idle tab it +showed nothing at all, and the honest fix for that β€” a button that manufactured traffic +so the graph had a shape β€” is a fair description of the problem: the panel needed to be +fed to look useful. + +Walk & Test does the active version of the same idea properly. It probes a fixed list of +destinations on a schedule, so the series means something specific, is comparable between +runs and between places, and is saved. Keeping both would have meant two live-updating +latency charts on adjacent screens measuring different things under similar-looking +labels, and the passive one is the weaker of the two. + +## What went with it + +- `src/components/TrafficMonitor.tsx`, and its `CapturedResource` and `TrafficSample` + types, which nothing else imported. +- The `httpbin.org` disclosure row that described the Trigger Network Spike button. + `httpbin.org` is still reachable from the app, as one of the HTTP Probe's one-click + sample targets, so the row was rewritten rather than deleted: it now names all four + sample targets and says they are only contacted on a button press. The disclosure list + is a contract, so it has to stay exactly as wide as the app's actual reach β€” no wider, + and no narrower. + +## If it comes back + +Two things would be worth fixing first. Report `transferSize` as absent rather than zero +for opaque responses, since an undercount presented as a total is a quiet inaccuracy. And +drop the traffic-generating button: if a panel needs synthetic load to be worth looking +at, the panel is answering a question nobody asked. diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 6c0fd30..b6e98b2 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -37,7 +37,6 @@ import { displayMetric } from './MetricValue'; import { BottleneckSummary } from './BottleneckSummary'; import { saveHistoryItem } from '../utils/storage'; import { ResponsibleNetworkingModal, isResponsibleNetworkingAccepted } from './ResponsibleNetworkingModal'; -import { TrafficMonitor } from './TrafficMonitor'; /** * One category readiness bar. A category with no measurement behind it shows an @@ -369,9 +368,6 @@ export const Dashboard: React.FC = ({ - {/* Real-Time PerformanceObserver Traffic Monitor Sparklines */} - - {/* Tool Launch Grid */}

diff --git a/src/components/PrivacySafetyModal.tsx b/src/components/PrivacySafetyModal.tsx index d599a37..c5650d1 100644 --- a/src/components/PrivacySafetyModal.tsx +++ b/src/components/PrivacySafetyModal.tsx @@ -75,14 +75,14 @@ export const THIRD_PARTY_DISCLOSURES: { host: string; receives: string }[] = [ 'are still theirs to log.', }, { - host: 'stun.l.google.com (and other STUN servers)', - receives: 'Your public IP, and potentially local network addresses, during WebRTC analysis.', + host: 'api.github.com, httpbin.org, cloudflare.com, dns.google', + receives: + 'Your IP, if you run the HTTP probe against one of the sample targets it offers as ' + + 'one-click suggestions. Nothing contacts them unless you press the button.', }, { - host: 'httpbin.org', - receives: - 'Your IP, only if you press β€œTrigger Network Spike” on the live traffic monitor, which ' + - 'makes a handful of requests so the sparklines have something real to draw.', + host: 'stun.l.google.com (and other STUN servers)', + receives: 'Your public IP, and potentially local network addresses, during WebRTC analysis.', }, { host: 'basemaps.cartocdn.com', diff --git a/src/components/TrafficMonitor.tsx b/src/components/TrafficMonitor.tsx deleted file mode 100644 index ed86336..0000000 --- a/src/components/TrafficMonitor.tsx +++ /dev/null @@ -1,661 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { - Activity, - Zap, - Pause, - Play, - Trash2, - Wifi, - Clock, - HardDrive, - Filter, - Layers, -} from 'lucide-react'; -import { - ResponsiveContainer, - AreaChart, - Area, - XAxis, - YAxis, - Tooltip, - CartesianGrid, -} from 'recharts'; -import { MetricValue } from './MetricValue'; - -export interface CapturedResource { - id: string; - name: string; - initiatorType: string; - duration: number; // ms - transferSize: number; // bytes - decodedBodySize: number; // bytes - startTime: number; // ms timestamp from performance.now() - timestamp: number; // absolute ms -} - -export interface TrafficSample { - timestamp: number; - timeLabel: string; - requestsCount: number; - transferBytes: number; - throughputKbps: number; - avgLatencyMs: number; - peakLatencyMs: number; -} - -export const TrafficMonitor: React.FC = () => { - const [isMonitoring, setIsMonitoring] = useState(true); - const [samples, setSamples] = useState([]); - const [recentResources, setRecentResources] = useState([]); - const [selectedFilter, setSelectedFilter] = useState('all'); - const [isGeneratingTraffic, setIsGeneratingTraffic] = useState(false); - const [totalCapturedCount, setTotalCapturedCount] = useState(0); - const [totalBytesCaptured, setTotalBytesCaptured] = useState(0); - - // Buffer for entries collected in current 1-second interval - const pendingEntriesRef = useRef([]); - const isMonitoringRef = useRef(isMonitoring); - - useEffect(() => { - isMonitoringRef.current = isMonitoring; - }, [isMonitoring]); - - // PerformanceObserver Setup - useEffect(() => { - // Initial backlog load from performance buffer - try { - const existing = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; - if (existing && existing.length > 0) { - const parsed: CapturedResource[] = existing.slice(-50).map((entry, i) => ({ - id: `init_${i}_${Date.now()}`, - name: entry.name, - initiatorType: entry.initiatorType || 'other', - duration: Math.max(1, Math.round(entry.duration)), - transferSize: entry.transferSize || entry.encodedBodySize || 0, - decodedBodySize: entry.decodedBodySize || 0, - startTime: entry.startTime, - timestamp: Date.now() - (performance.now() - entry.startTime), - })); - pendingEntriesRef.current.push(...parsed); - } - } catch (e) { - console.warn('Unable to read initial performance entries:', e); - } - - let observer: PerformanceObserver | null = null; - if (typeof PerformanceObserver !== 'undefined') { - try { - observer = new PerformanceObserver((list) => { - if (!isMonitoringRef.current) return; - const entries = list.getEntries() as PerformanceResourceTiming[]; - const newCaptured: CapturedResource[] = entries.map((entry, idx) => ({ - id: `res_${Date.now()}_${idx}_${Math.random().toString(36).substring(2, 6)}`, - name: entry.name, - initiatorType: entry.initiatorType || 'fetch', - duration: Math.max(1, Math.round(entry.duration)), - transferSize: entry.transferSize || entry.encodedBodySize || 0, - decodedBodySize: entry.decodedBodySize || 0, - startTime: entry.startTime, - timestamp: Date.now(), - })); - - pendingEntriesRef.current.push(...newCaptured); - }); - - observer.observe({ entryTypes: ['resource'] }); - } catch (err) { - console.warn('PerformanceObserver resource observation error:', err); - } - } - - return () => { - if (observer) { - observer.disconnect(); - } - }; - }, []); - - // Interval timer to aggregate samples every 1 second - useEffect(() => { - const MAX_SAMPLES = 30; // 30-second sliding sparkline window - - const interval = setInterval(() => { - // Pause means pause. This guard was missing, so "Pause" only stopped the - // PerformanceObserver from collecting β€” the aggregation tick kept pushing - // empty samples and re-rendering the whole Dashboard subtree every second, - // indefinitely. - if (!isMonitoringRef.current) return; - - const now = new Date(); - const timeLabel = now.toLocaleTimeString([], { - hour12: false, - minute: '2-digit', - second: '2-digit', - }); - - const currentBatch = [...pendingEntriesRef.current]; - pendingEntriesRef.current = []; - - const requestsCount = currentBatch.length; - let transferBytes = 0; - let totalLatency = 0; - let peakLatencyMs = 0; - - if (requestsCount > 0) { - currentBatch.forEach((res) => { - transferBytes += res.transferSize; - totalLatency += res.duration; - if (res.duration > peakLatencyMs) { - peakLatencyMs = res.duration; - } - }); - } - - const avgLatencyMs = requestsCount > 0 ? Math.round(totalLatency / requestsCount) : 0; - const throughputKbps = Math.round((transferBytes * 8) / 1024); // kilobits per second for 1s frame - - setTotalCapturedCount((prev) => prev + requestsCount); - setTotalBytesCaptured((prev) => prev + transferBytes); - - const newSample: TrafficSample = { - timestamp: Date.now(), - timeLabel, - requestsCount, - transferBytes, - throughputKbps, - avgLatencyMs, - peakLatencyMs, - }; - - setSamples((prev) => { - const updated = [...prev, newSample]; - if (updated.length > MAX_SAMPLES) { - return updated.slice(updated.length - MAX_SAMPLES); - } - return updated; - }); - - if (currentBatch.length > 0) { - setRecentResources((prev) => { - const combined = [...currentBatch.reverse(), ...prev]; - return combined.slice(0, 40); - }); - } - }, 1000); - - return () => clearInterval(interval); - }, []); - - // Generate simulated test requests to show live spikes on sparkline - const handleGenerateTestTraffic = async () => { - setIsGeneratingTraffic(true); - const endpoints = [ - 'https://1.1.1.1/cdn-cgi/trace', - 'https://dns.google/resolve?name=example.com&type=A', - 'https://cloudflare-dns.com/dns-query?name=cloudflare.com&type=A', - 'https://httpbin.org/get', - ]; - - try { - const promises = endpoints.map((url) => - fetch(`${url}${url.includes('?') ? '&' : '?'}cache_bust=${Date.now()}_${Math.random()}`, { - cache: 'no-store', - mode: 'cors', - }).catch(() => null) - ); - - await Promise.all(promises); - } catch (e) { - console.warn('Test traffic error:', e); - } finally { - setTimeout(() => setIsGeneratingTraffic(false), 600); - } - }; - - const handleClearHistory = () => { - setSamples([]); - setRecentResources([]); - setTotalCapturedCount(0); - setTotalBytesCaptured(0); - }; - - // Filtered resources for recent stream table - const filteredResources = recentResources.filter((res) => { - if (selectedFilter === 'all') return true; - if (selectedFilter === 'fetch') return res.initiatorType === 'fetch' || res.initiatorType === 'xmlhttprequest'; - if (selectedFilter === 'script') return res.initiatorType === 'script'; - if (selectedFilter === 'css') return res.initiatorType === 'css' || res.initiatorType === 'link'; - if (selectedFilter === 'img') return res.initiatorType === 'img' || res.initiatorType === 'image'; - return res.initiatorType === selectedFilter; - }); - - // Calculate current live metrics from last 5 samples - const activeSamples = samples.slice(-5); - const currentRequestsPerSec = activeSamples.length - ? Math.round(activeSamples.reduce((acc, s) => acc + s.requestsCount, 0) / activeSamples.length) - : 0; - // Only samples that actually contain a request carry a latency. With none, the - // answer is "no latency to average", not "0 ms" β€” the `|| 1` denominator here - // used to divide a sum of zeros by one and render a confident 0 ms while the - // machine was offline and nothing had been requested at all. - const samplesWithRequests = activeSamples.filter((s) => s.requestsCount > 0); - const currentAvgLatency: number | null = - samplesWithRequests.length > 0 - ? Math.round( - samplesWithRequests.reduce((acc, s) => acc + s.avgLatencyMs, 0) / - samplesWithRequests.length, - ) - : null; - const currentKbps = activeSamples.length - ? Math.round(activeSamples.reduce((acc, s) => acc + s.throughputKbps, 0) / activeSamples.length) - : 0; - - const formatBytes = (bytes: number) => { - if (bytes === 0) return '0 B'; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; - }; - - const formatUrlName = (url: string) => { - try { - const parsed = new URL(url); - return { host: parsed.host, path: parsed.pathname + parsed.search }; - } catch { - return { host: 'resource', path: url.length > 50 ? url.substring(0, 50) + '...' : url }; - } - }; - - return ( -
- {/* Background Subtle Accent */} -
- - {/* Header & Controls */} -
-
-
- - - -

- Real-Time Network Traffic Monitor -

- - PerformanceObserver API - -
-

- Live browser network throughput (Kbps / requests) and response latency (ms) sparkline metrics recorded in real-time. -

-
- -
- - - - - -
-
- - {/* Top Telemetry KPI Cards */} -
- {/* Current Throughput */} -
-
- Throughput - -
-
- - {currentKbps} - - Kbps -
-
- {currentRequestsPerSec} req/sec live rate -
-
- - {/* Avg Response Latency */} -
-
- Avg Latency - -
-
- - - - {currentAvgLatency !== null && ( - ms - )} -
-
- Last 5s rolling average -
-
- - {/* Total Captured Requests */} -
-
- Captured Requests - -
-
- - {totalCapturedCount} - - reqs -
-
- Recorded in session -
-
- - {/* Total Transferred Payload */} -
-
- Payload Size - -
-
- - {formatBytes(totalBytesCaptured)} - -
-
- Transferred over network -
-
-
- - {/* Sparkline Charts Grid */} -
- {/* 1. Network Throughput & Request Count Sparkline */} -
-
-
- -

- Network Throughput (Kbps) -

-
- 30s Window -
- -
- {samples.length === 0 ? ( -
- Waiting for network activity... -
- ) : ( - - - - - - - - - - - - [`${val} Kbps`, 'Throughput']} - labelFormatter={(lbl) => `Time: ${lbl}`} - /> - - - - )} -
-
- - {/* 2. Response Latency Sparkline */} -
-
-
- -

- Response Latency (ms) -

-
- Peak & Avg ms -
- -
- {samples.length === 0 ? ( -
- Waiting for network activity... -
- ) : ( - - - - - - - - - - - - [ - `${val} ms`, - name === 'avgLatencyMs' ? 'Avg Latency' : 'Peak Latency', - ]} - labelFormatter={(lbl) => `Time: ${lbl}`} - /> - - - - - )} -
-
-
- - {/* Captured Resource Requests Feed Table */} -
-
-
- -

- Recent Network Resource Log ({filteredResources.length}) -

-
- - {/* Initiator Type Filter Tabs */} -
- {['all', 'fetch', 'script', 'css', 'img'].map((type) => ( - - ))} -
-
- - {filteredResources.length === 0 ? ( -
- No network request entries matching filter. Trigger test traffic above or perform actions in the app! -
- ) : ( -
- - - - - - - - - - - - {filteredResources.slice(0, 12).map((res) => { - const urlFormatted = formatUrlName(res.name); - return ( - - - - - - - - - - - - ); - })} - -
Resource TargetInitiatorLatencyTransfer SizeCaptured
- - {urlFormatted.host} - - - {urlFormatted.path} - - - - {res.initiatorType} - - - - {res.duration} ms - - - {formatBytes(res.transferSize)} - - {new Date(res.timestamp).toLocaleTimeString([], { - hour12: false, - minute: '2-digit', - second: '2-digit', - })} -
-
- )} -
-
- ); -}; diff --git a/src/components/WalkTest.tsx b/src/components/WalkTest.tsx index 87537a9..b44d213 100644 --- a/src/components/WalkTest.tsx +++ b/src/components/WalkTest.tsx @@ -10,6 +10,8 @@ import { WifiOff, } from 'lucide-react'; import { + Area, + AreaChart, Bar, CartesianGrid, ComposedChart, @@ -24,19 +26,23 @@ import { import type { HistoryItem, WalkSample, + WalkTarget, WalkTargetStats, WalkTestResult, WalkWaypoint, } from '../types'; import { + CHART_WINDOW_ROUNDS, DEFAULT_INTERVAL_MS, INTERVAL_CHOICES, MAX_STORED_SAMPLES, PROBE_TIMEOUT_MS, WALK_TARGETS, + WARMUP_ROUNDS, buildWalkConclusions, buildWalkResult, createWaypoint, + isSteadyState, runWalkLoop, summariseTarget, summariseWaypoint, @@ -50,17 +56,21 @@ import { StorageFullError, saveHistoryItem } from '../utils/storage'; * * The screen has one job the rest of the suite does not: it has to stay * readable while the person holding the phone is looking at a wall socket - * rather than at it. So the live table is the product, the chart is secondary, - * and the single most important control β€” "I have moved, start a new spot" β€” is - * a large button that never scrolls out of the way while a walk is running. + * rather than at it. So the destination cards are the product, the aggregate + * chart is secondary, and the single most important control β€” "I have moved, + * start a new spot" β€” is a large button that never scrolls out of the way while + * a walk is running. * - * The honesty rules cost more here than usual and are worth stating. A - * destination that has not answered yet shows an em-dash and no bar: a - * zero-length bar in a latency table reads as "instant", which is the exact - * opposite of what a dead spot means. And a median only appears once there are - * enough samples behind it, which on a three-second interval is about ten - * seconds of standing still β€” the panel says so, because otherwise the empty - * cells look like a broken tool rather than an honest one. + * Two honesty rules cost more here than usual and are worth stating. + * + * A destination with no measurement shows an em-dash and no sparkline. A + * flat line at the bottom of a latency card reads as "instant", which is the + * exact opposite of what a dead spot means, so there is no line at all. + * + * Every sparkline shares one y-scale, printed above the grid. Per-card scaling + * would make a 400 ms destination and a 40 ms destination draw identical + * shapes, and the whole reason for putting ten cards side by side is to compare + * them at a glance. */ interface WalkTestProps { @@ -70,12 +80,12 @@ interface WalkTestProps { type SortKey = 'median' | 'last' | 'answered' | 'label'; const CATEGORY_BADGE: Record<'consumer' | 'business', string> = { - consumer: 'bg-fuchsia-500/15 text-fuchsia-300', - business: 'bg-sky-500/15 text-sky-300', + consumer: 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/25', + business: 'bg-sky-500/15 text-sky-300 border-sky-500/25', }; -/** Latency bands, for the row tint only. Deliberately coarse and never shown as - * a grade: these are HTTPS round trips to third-party edges, and turning them +/** Latency bands, for colour only. Deliberately coarse and never shown as a + * grade: these are HTTPS round trips to third-party edges, and turning them * into a letter would imply a precision the measurement does not have. */ const tone = (ms: number | null): string => { if (ms === null) return 'text-slate-600'; @@ -85,6 +95,14 @@ const tone = (ms: number | null): string => { return 'text-rose-300'; }; +const strokeFor = (ms: number | null): string => { + if (ms === null) return '#475569'; + if (ms < 100) return '#34d399'; + if (ms < 250) return '#22d3ee'; + if (ms < 600) return '#fbbf24'; + return '#fb7185'; +}; + const formatDuration = (ms: number): string => { const total = Math.round(ms / 1000); const mins = Math.floor(total / 60); @@ -92,6 +110,181 @@ const formatDuration = (ms: number): string => { return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; }; +/** One point per round for one destination. `ms` is null for a round the + * destination did not answer, or one whose timing was discarded as warm-up. */ +interface CardPoint { + round: number; + ms: number | null; +} + +interface DestinationCardProps { + target: WalkTarget | undefined; + stats: WalkTargetStats; + series: CardPoint[]; + /** Shared across every card so the shapes are comparable. Null when nothing + * has been measured yet, in which case no card draws a line. */ + yMax: number | null; + hasStarted: boolean; +} + +const DestinationCard: React.FC = ({ + target, + stats, + series, + yMax, + hasStarted, +}) => { + const unanswered = stats.summary.attempted - stats.summary.answered; + const dead = stats.summary.attempted > 0 && stats.summary.answered === 0; + const plotted = yMax === null ? 0 : series.filter((p) => p.ms !== null).length; + const stroke = strokeFor(stats.summary.medianMs ?? stats.lastRoundTripMs); + const gradientId = `walkgrad_${stats.targetId}`; + + const lastFailure = + stats.lastOutcome === 'no-response' + ? { + metric: stats.targetId, + reason: 'api-unreachable' as const, + detail: `The last probe to ${stats.label} did not come back.`, + } + : undefined; + + const summaryFailure = + stats.summary.medianMs === null + ? { + metric: stats.targetId, + reason: dead ? ('api-unreachable' as const) : ('insufficient-samples' as const), + detail: dead + ? `${stats.label} has not answered any probe.` + : `${stats.label} needs ${MIN_SAMPLES_FOR_SUMMARY} answers after the warm-up rounds ` + + 'before a median means anything.', + } + : undefined; + + return ( +
+
+
+ {stats.label} + + {stats.category === 'consumer' ? 'cons' : 'biz'} + +
+
+ {target?.host} +
+
+ +
+
+ + + + {stats.lastRoundTripMs !== null && ( + ms + )} +
+
+ {/* A plain string: `{count && …}` puts a bare zero on the page, which + has shipped in this app before. */} +
0 ? 'text-amber-300' : 'text-slate-400'}`} + > + {`${stats.summary.answered} / ${stats.summary.attempted}`} +
+
+ answered +
+
+
+ + {/* No measurement, no line. A flat trace at the bottom would read as + "instant", which is the opposite of what an empty card means. */} +
+ {plotted >= 2 ? ( + + + + + + + + + + + {/* Zero-based and shared with every other card. An auto domain + would magnify a 3 ms wobble into a mountain range. */} + + [typeof v === 'number' ? `${v} ms` : 'β€”', stats.label]} + labelFormatter={(l) => `Round ${l}`} + /> + {/* connectNulls stays off: a round with no answer leaves a gap, + and drawing through it would invent a measurement. */} + + + + ) : ( +
+ {!hasStarted + ? 'no probes yet' + : dead + ? 'no answer from this destination' + : 'not enough measured rounds to draw'} +
+ )} +
+ +
+ {[ + { label: 'median', value: stats.summary.medianMs }, + { label: 'jitter', value: stats.jitterMs }, + { label: 'max', value: stats.summary.maxMs }, + ].map((cell) => ( +
+
+ +
+
+ {cell.label} +
+
+ ))} +
+
+ ); +}; + export const WalkTest: React.FC = ({ onHistoryUpdate }) => { const [isWalking, setIsWalking] = useState(false); const [samples, setSamples] = useState([]); @@ -237,7 +430,7 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => { } // Absent values sort last in every direction. A destination that produced - // nothing must never surface at the top of a column headed "fastest". + // nothing must never surface at the top of a list headed "fastest". const value = (row: WalkTargetStats): number | null => sortKey === 'median' ? row.summary.medianMs : row.lastRoundTripMs; return rows.sort((a, b) => { @@ -251,71 +444,104 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => { }, [perTarget, sortKey]); /** - * One point per round. + * Everything the charts draw, computed in one pass over the visible window. * - * `answering` is a straight count and means exactly what it says. `medianMs` - * is the median across whichever destinations answered *that* round, so when - * destinations drop out the line moves partly because the set changed and not - * only because the network did. That is a real trap, so the count is plotted - * beside it and the caption says so β€” the per-spot table below is the figure - * that is safe to compare, because it is paired. + * Only steady-state samples become points: the warm-up rounds and each + * destination's first answer carry DNS, TCP and TLS, and leaving them in + * pinned the y-axis near a second for the rest of the session, which flattened + * every real measurement into a line along the bottom. */ - const timeline = useMemo(() => { - const byRound = new Map(); + const charts = useMemo(() => { + const rounds = [...new Set(samples.map((s) => s.round))].sort((a, b) => a - b); + const visible = rounds.slice(Math.max(0, rounds.length - CHART_WINDOW_ROUNDS)); + const visibleSet = new Set(visible); + + const byTarget = new Map>(); + const answeringByRound = new Map(); + const timingsByRound = new Map(); + let peak = 0; + for (const s of samples) { - const list = byRound.get(s.round); - if (list === undefined) byRound.set(s.round, [s]); - else list.push(s); + if (!visibleSet.has(s.round)) continue; + + let lane = byTarget.get(s.targetId); + if (lane === undefined) { + lane = new Map(); + byTarget.set(s.targetId, lane); + } + + if (isSteadyState(s)) { + const ms = s.roundTripMs as number; + lane.set(s.round, ms); + if (ms > peak) peak = ms; + const bucket = timingsByRound.get(s.round); + if (bucket === undefined) timingsByRound.set(s.round, [ms]); + else bucket.push(ms); + } else { + lane.set(s.round, null); + } + + if (s.outcome === 'answered') { + answeringByRound.set(s.round, (answeringByRound.get(s.round) ?? 0) + 1); + } } - return [...byRound.entries()] - .sort((a, b) => a[0] - b[0]) - .map(([roundNumber, roundSamples]) => { - const times = roundSamples - .filter((s) => s.outcome === 'answered' && !s.connectionSetup && s.roundTripMs !== null) - .map((s) => s.roundTripMs as number); - const mid = median(times); - return { - round: roundNumber, - answering: roundSamples.filter((s) => s.outcome === 'answered').length, - medianMs: mid === null ? null : Math.round(mid), - }; - }); + const seriesFor = (targetId: string): CardPoint[] => { + const lane = byTarget.get(targetId); + return visible.map((r) => ({ round: r, ms: lane?.get(r) ?? null })); + }; + + const timeline = visible.map((r) => { + const mid = median(timingsByRound.get(r) ?? []); + return { + round: r, + answering: answeringByRound.get(r) ?? 0, + medianMs: mid === null ? null : Math.round(mid), + }; + }); + + // Shared ceiling, rounded up to something legible. Null when nothing has + // been measured: a default of "10 ms" would be a number on the page that no + // probe produced, which is exactly what this project does not do. Offline, + // that caption printed "0 to 10 ms" and it read like a measurement. + const step = peak > 500 ? 100 : peak > 100 ? 50 : 10; + const yMax = peak > 0 ? Math.ceil(peak / step) * step : null; + + return { seriesFor, timeline, yMax, windowRounds: visible.length, totalRounds: rounds.length }; }, [samples]); - /** The round each spot began at, for the chart's dividers. */ - const spotBoundaries = useMemo( - () => - waypoints - .map((w) => { - const first = samples.find((s) => s.waypointId === w.id); - return first === undefined ? null : { round: first.round, label: w.label }; - }) - .filter((b): b is { round: number; label: string } => b !== null), - [waypoints, samples], - ); + /** The round each spot began at, for the aggregate chart's dividers. Only + * spots inside the visible window can be drawn. */ + const spotBoundaries = useMemo(() => { + const firstVisible = charts.timeline.length > 0 ? charts.timeline[0].round : 0; + return waypoints + .map((w) => { + const first = samples.find((s) => s.waypointId === w.id); + return first === undefined || first.round < firstVisible + ? null + : { round: first.round, label: w.label }; + }) + .filter((b): b is { round: number; label: string } => b !== null); + }, [waypoints, samples, charts.timeline]); const currentSpot = waypoints.length > 0 ? waypoints[waypoints.length - 1] : null; const totalAttempted = perTarget.reduce((sum, t) => sum + t.summary.attempted, 0); const totalAnswered = perTarget.reduce((sum, t) => sum + t.summary.answered, 0); const hasData = totalAttempted > 0; + const inWarmUp = hasData && round <= WARMUP_ROUNDS; - const SortHeader: React.FC<{ id: SortKey; children: React.ReactNode; align?: string }> = ({ - id, - children, - align = 'text-right', - }) => ( - - - + const SortButton: React.FC<{ id: SortKey; children: React.ReactNode }> = ({ id, children }) => ( + ); return ( @@ -328,9 +554,9 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => {

Walk & Test

- Ten destinations people actually depend on β€” five consumer, five business β€” probed - over and over while you walk the building. Name the spot you are standing in, wait a - few rounds, move, name the next one. The table below settles as samples arrive; the + Ten destinations people actually depend on β€” four consumer, six business β€” probed over + and over while you walk the building. Name the spot you are standing in, wait a few + rounds, move, name the next one. The cards below settle as samples arrive; the per-spot comparison at the bottom is what you came for.

@@ -375,6 +601,7 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => { disabled={isWalking} onChange={(e) => setIntervalMs(Number(e.target.value))} className="bg-slate-800 border border-slate-700 rounded-lg px-2 py-1.5 text-xs text-slate-200 disabled:opacity-50" + title="The pause between rounds. A round waits for every destination to answer or time out first, so a slow round stretches the gap rather than piling requests up." > {INTERVAL_CHOICES.map((ms) => (

)} - {/* Live destination table. Present from the first round, because watching - it fill in is how you know the walk is working. */} -
-
- - - - - Destination - - Last - Median - - - - Answered - - - - {sorted.map((row) => { - const target = targets.find((t) => t.id === row.targetId); - const unanswered = row.summary.attempted - row.summary.answered; - const failure = - row.summary.medianMs === null && row.summary.attempted > 0 - ? { - metric: row.targetId, - reason: - row.summary.answered === 0 - ? ('api-unreachable' as const) - : ('insufficient-samples' as const), - detail: - row.summary.answered === 0 - ? `${row.label} has not answered any probe yet.` - : `${row.label} needs ${MIN_SAMPLES_FOR_SUMMARY} answers after its first ` + - 'before a median means anything.', - } - : undefined; - - return ( - 0 ? 'bg-rose-500/[0.04]' : ''}> - - - - - - - - - ); - })} - -
MinMaxJitter
-
- {row.label} - - {row.category} - -
-
- {target?.host} -
-
- - - - - - - - - - - {/* Rendered as a plain string. `{count && …}` puts a bare - zero on the page, which has shipped in this app before. */} - 0 ? 'text-amber-300' : 'text-slate-400'}> - {`${row.summary.answered} / ${row.summary.attempted}`} - -
+ {/* Destination cards. One per probed service, 1 to 5 across depending on + the width available. */} +
+
+
+

Destinations

+

+ {!hasData + ? 'Nothing probed yet.' + : charts.yMax === null + ? 'No round trip has been measured yet, so there is nothing to plot and no scale to state.' + : `Every sparkline shares one scale, 0 to ${charts.yMax} ms, so the shapes are + comparable. ${ + charts.totalRounds > charts.windowRounds + ? `Showing the last ${charts.windowRounds} of ${charts.totalRounds} rounds.` + : '' + }`} +

+
+
+ + sort + + median + last + answered + name +
-
- {hasData - ? `Median, min, max and jitter ignore each destination's very first probe, which pays for - DNS, TCP and TLS on top of the round trip. They stay blank until ${MIN_SAMPLES_FOR_SUMMARY} - later answers exist β€” roughly ${Math.round( - ((MIN_SAMPLES_FOR_SUMMARY + 1) * intervalMs) / 1000, - )} seconds of standing still at this interval. A probe is given - ${PROBE_TIMEOUT_MS / 1000} seconds before it counts as unanswered.` - : 'No probes yet. Press β€œStart walking”, name the spot you are standing in, and let a few rounds run before you move.'} + + {inWarmUp && ( +
+ + + Warm-up. The first {WARMUP_ROUNDS} rounds pay for DNS, TCP and TLS, so their timings + are discarded rather than charted β€” they would set the top of every scale for the rest + of the walk. Counts still include them. + +
+ )} + +
+ {sorted.map((stats) => ( + t.id === stats.targetId)} + stats={stats} + series={charts.seriesFor(stats.targetId)} + yMax={charts.yMax} + hasStarted={hasData} + /> + ))}
+ +

+ Median, jitter and max ignore the opening {WARMUP_ROUNDS} rounds and each + destination’s first answer, all of which carry connection setup. They stay blank + until {MIN_SAMPLES_FOR_SUMMARY} later answers exist, which is about{' '} + {Math.round(((WARMUP_ROUNDS + MIN_SAMPLES_FOR_SUMMARY + 1) * intervalMs) / 1000)} seconds + of standing still at this interval. A probe is given {PROBE_TIMEOUT_MS / 1000} seconds + before it counts as unanswered. +

- {/* Permanent, not collapsible. Without it the table reads as a ping - comparison between ten companies, which it is not. + {/* Permanent, not collapsible. Without it the cards read as a ping + comparison between ten companies, which they are not. - It sits below the table rather than above it because on a phone β€” which - is where a walk test is actually run β€” seven hundred pixels of caveats - before the live numbers means scrolling past them at every spot. The - table's own footer carries the exclusions that change how a cell is - read; this panel carries the ones that change what the whole tool - means, and nothing here is behind a disclosure triangle. */} + It sits below the cards rather than above them because on a phone β€” + which is where a walk test is actually run β€” seven hundred pixels of + caveats before the live numbers means scrolling past them at every + spot. The cards' own footer carries the exclusions that change how a + figure is read; this panel carries the ones that change what the whole + tool means, and nothing here is behind a disclosure triangle. */}
@@ -613,14 +774,16 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => { {' '} You are measuring the CDN edge that terminates TLS near you. Netflix playback, Teams call audio and Zoom media all run over paths a browser cannot address at all, so a green - row here does not promise a smooth call. + card here does not promise a smooth call.
  • Unanswered is not the same as down, and it is not packet loss. {' '} A timeout, a refused connection, a failed name lookup and being out of range all look - identical to a browser. The column counts answers and is called exactly that. + identical to a browser. A blocklist counts too: an ad blocker or a filtering resolver + can make a perfectly healthy network look like a dead spot for one destination. The + counter is called answered for that reason.
  • @@ -628,25 +791,38 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => { {' '} That gives every destination the same instant, which is the point when the phone is moving β€” but on a constrained link they also compete with each other, which lifts all - ten together. Compare rows to each other and spots to each other; do not read a single + ten together. Compare cards to each other and spots to each other; do not read a single figure as this connection’s latency.
  • - {timeline.length > 1 && ( + {charts.timeline.length > 1 && (
    -
    - Round by round +
    +
    + Round by round +
    +
    + {charts.totalRounds > charts.windowRounds + ? `last ${charts.windowRounds} rounds` + : `${charts.windowRounds} rounds`} +
    - + {/* Both axes start at zero: a truncated axis turns a 10 ms difference into a cliff. */} @@ -684,9 +860,8 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => { fill="#14b8a6" opacity={0.35} radius={[2, 2, 0, 0]} + isAnimationActive={false} /> - {/* connectNulls stays off: a round where nothing answered leaves a - gap, and drawing through it would invent a measurement. */} = ({ onHistoryUpdate }) => { strokeWidth={2} dot={false} connectNulls={false} + isAnimationActive={false} /> {spotBoundaries.map((b) => ( = ({ onHistoryUpdate }) => {

    - The bars are a straight count and mean what they say. The line starts at round two, - because round one is every destination’s connection-setup probe and carries DNS, - TCP and TLS inside it. The line is the median across whichever destinations answered{' '} - that round, so when destinations drop out it + The bars are a straight count and mean what they say. The line is the median across + whichever destinations answered that round, so when destinations drop out it moves partly because the set changed and not only because the network did β€” watch the bars alongside it. The per-spot table below does not have that problem: it compares only the destinations that produced a median at every spot. @@ -726,9 +900,10 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => {

    Spot by spot

    - Each cell is that destination’s median at that spot. The overall column is the - median of those medians, so one chatty destination cannot dominate it and it does not - lurch when a destination stops answering. + Each cell is that destination’s median at that spot, over the whole walk rather + than the charted window. The overall column is the median of those medians, so one + chatty destination cannot dominate it and it does not lurch when a destination stops + answering.

    @@ -749,18 +924,17 @@ export const WalkTest: React.FC = ({ onHistoryUpdate }) => { {perWaypoint.map((w) => { const unanswered = w.attempted - w.answered; + const spot = waypoints.find((p) => p.id === w.waypointId); return (
    {w.label}
    - {waypoints.find((p) => p.id === w.waypointId)?.reportedEffectiveType !== - null && ( + {spot?.reportedEffectiveType !== null && (
    - browser reported{' '} - {waypoints.find((p) => p.id === w.waypointId)?.reportedEffectiveType} + browser reported {spot?.reportedEffectiveType}
    )} diff --git a/src/types.ts b/src/types.ts index 3524955..5752e75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -730,10 +730,14 @@ export interface WalkSample { roundTripMs: number | null; outcome: WalkProbeOutcome; /** - * True for the first probe against this destination in this run, which pays - * for DNS, TCP and TLS on top of the round trip. It is kept and counted, and - * excluded from the timing statistics, the same way the DNS benchmark - * discards its warm-up query. + * True for the first probe this destination *answers* in this run, which pays + * for DNS, TCP and TLS on top of the round trip. Keyed on the first answer + * rather than the first attempt: a destination that is silent for twenty + * rounds and then replies paid its handshake on round twenty-one. + * + * Kept and counted, and excluded from the timing statistics along with the + * opening warm-up rounds, the same way the DNS benchmark discards its warm-up + * query. */ connectionSetup: boolean; } diff --git a/src/utils/walkTest.test.ts b/src/utils/walkTest.test.ts index cb975d6..7c14970 100644 --- a/src/utils/walkTest.test.ts +++ b/src/utils/walkTest.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'vitest'; import type { WalkSample, WalkTarget, WalkWaypoint } from '../types'; import { + DEFAULT_INTERVAL_MS, + INTERVAL_CHOICES, MAX_STORED_SAMPLES, WALK_TARGETS, + WARMUP_ROUNDS, buildWalkConclusions, buildWalkResult, createWaypoint, + isSteadyState, medianOfTargetMedians, summariseTarget, summariseWaypoint, @@ -29,12 +33,26 @@ const waypoint = (id: string, label = id): WalkWaypoint => ({ reportedEffectiveType: null, }); -/** Builds samples for one target: `times` of null means no response. */ +/** First round whose timings count. Rounds up to WARMUP_ROUNDS are discarded. */ +const FIRST_MEASURED_ROUND = WARMUP_ROUNDS + 1; + +/** + * Builds samples for one target; `null` means no response. + * + * Defaults to starting after the warm-up rounds, so a test that is about + * medians does not have to think about them. Pass `startRound` to place samples + * inside the warm-up window on purpose. + * + * `connectionSetup` is never set here. It is a property of a whole walk (the + * first answer a destination gives, once), not of one call to this helper, so + * marking it per call would wrongly discard a sample every time a destination + * appears at a second waypoint. Tests that care use `withSetup`. + */ const samplesFor = ( targetId: string, waypointId: string, times: (number | null)[], - startRound = 1, + startRound = FIRST_MEASURED_ROUND, ): WalkSample[] => times.map((ms, i) => ({ targetId, @@ -43,17 +61,25 @@ const samplesFor = ( timestamp: 1_700_000_000_000 + i * 1000, roundTripMs: ms, outcome: ms === null ? ('no-response' as const) : ('answered' as const), - connectionSetup: i === 0 && startRound === 1, + connectionSetup: false, })); +/** Marks the first answered sample as the one that paid for DNS, TCP and TLS, + * which is what `runWalkLoop` does. */ +const withSetup = (samples: WalkSample[]): WalkSample[] => { + const first = samples.findIndex((s) => s.outcome === 'answered'); + if (first === -1) return samples; + return samples.map((s, i) => (i === first ? { ...s, connectionSetup: true } : s)); +}; + describe('summariseTarget', () => { it('reports no statistics at all below the minimum sample count', () => { const stats = summariseTarget(target('a'), samplesFor('a', 'w1', [50, 60])); expect(stats.summary.attempted).toBe(2); expect(stats.summary.answered).toBe(2); - // Two samples, one of which is the connection-setup probe, leaves one - // usable timing. Everything derived is absent, not small. + // Two usable timings is below the minimum. Everything derived is absent, + // not small. expect(stats.summary.medianMs).toBeNull(); expect(stats.summary.minMs).toBeNull(); expect(stats.summary.maxMs).toBeNull(); @@ -63,7 +89,7 @@ describe('summariseTarget', () => { it('excludes the connection-setup probe from the timings but not the counts', () => { // The first sample carries DNS, TCP and TLS. If it leaked into the median // the answer would be 60, not 40. - const stats = summariseTarget(target('a'), samplesFor('a', 'w1', [900, 30, 40, 50])); + const stats = summariseTarget(target('a'), withSetup(samplesFor('a', 'w1', [900, 30, 40, 50]))); expect(stats.summary.attempted).toBe(4); expect(stats.summary.answered).toBe(4); @@ -72,7 +98,10 @@ describe('summariseTarget', () => { }); it('counts unanswered probes without inventing a time for them', () => { - const stats = summariseTarget(target('a'), samplesFor('a', 'w1', [20, 30, null, 40, 50])); + const stats = summariseTarget( + target('a'), + withSetup(samplesFor('a', 'w1', [20, 30, null, 40, 50])), + ); expect(stats.summary.attempted).toBe(5); expect(stats.summary.answered).toBe(4); @@ -81,7 +110,7 @@ describe('summariseTarget', () => { }); it('keeps the last outcome even when the last probe failed', () => { - const stats = summariseTarget(target('a'), samplesFor('a', 'w1', [20, 30, 40, null])); + const stats = summariseTarget(target('a'), withSetup(samplesFor('a', 'w1', [20, 30, 40, null]))); expect(stats.lastOutcome).toBe('no-response'); expect(stats.lastRoundTripMs).toBeNull(); @@ -105,7 +134,70 @@ describe('summariseTarget', () => { }); it('gives no jitter from a single usable timing', () => { - expect(summariseTarget(target('a'), samplesFor('a', 'w1', [100, 50])).jitterMs).toBeNull(); + const stats = summariseTarget(target('a'), withSetup(samplesFor('a', 'w1', [100, 50]))); + expect(stats.jitterMs).toBeNull(); + }); + + it('discards the opening warm-up rounds of a walk', () => { + // Rounds 1 and 2 are wildly slow because the path is cold. Left in, they + // would set the top of the chart's axis for the rest of the session. + const samples = [ + ...samplesFor('a', 'w1', [4000, 3000], 1), + ...samplesFor('a', 'w1', [40, 42, 44]), + ]; + const stats = summariseTarget(target('a'), samples); + + expect(stats.summary.attempted).toBe(5); + expect(stats.summary.answered).toBe(5); + expect(stats.summary.medianMs).toBe(42); + expect(stats.summary.maxMs).toBe(44); + }); + + it('still counts a warm-up round that failed', () => { + // Discarding a timing is not the same as pretending the probe never + // happened. A destination that was unreachable at the start was unreachable. + const samples = [ + ...samplesFor('a', 'w1', [null, null], 1), + ...samplesFor('a', 'w1', [40, 42, 44]), + ]; + const stats = summariseTarget(target('a'), samples); + + expect(stats.summary.attempted).toBe(5); + expect(stats.summary.answered).toBe(3); + expect(stats.summary.medianMs).toBe(42); + }); +}); + +describe('isSteadyState', () => { + // The charts and the statistics both filter on this, so they cannot disagree + // about which samples exist. That is the whole reason it is exported. + const sample = (over: Partial): WalkSample => ({ + targetId: 'a', + waypointId: 'w1', + round: FIRST_MEASURED_ROUND, + timestamp: 1, + roundTripMs: 40, + outcome: 'answered', + connectionSetup: false, + ...over, + }); + + it('accepts an answered probe past the warm-up rounds', () => { + expect(isSteadyState(sample({}))).toBe(true); + }); + + it('rejects every warm-up round', () => { + for (let round = 1; round <= WARMUP_ROUNDS; round++) { + expect(isSteadyState(sample({ round }))).toBe(false); + } + }); + + it('rejects the connection-setup probe wherever it lands', () => { + expect(isSteadyState(sample({ round: 40, connectionSetup: true }))).toBe(false); + }); + + it('rejects an unanswered probe', () => { + expect(isSteadyState(sample({ outcome: 'no-response', roundTripMs: null }))).toBe(false); }); }); @@ -365,6 +457,14 @@ describe('WALK_TARGETS', () => { expect(WALK_TARGETS.some((t) => t.category === 'business')).toBe(true); }); + it('leaves out destinations that common blocklists kill', () => { + // Meta domains sit on most ad and tracker blocklists, so that row failed + // for people whose network was entirely healthy. A false alarm in a + // diagnostic is worse than one fewer destination. + const blocked = ['www.facebook.com', 'www.instagram.com', 'connect.facebook.net']; + expect(WALK_TARGETS.filter((t) => blocked.includes(t.host))).toEqual([]); + }); + it('probes a terminal URL, not one that redirects', () => { // A `no-cors` request must follow redirects β€” the browser rejects any other // redirect mode outright β€” so a bouncing URL would fold two round trips into @@ -384,6 +484,19 @@ describe('WALK_TARGETS', () => { }); }); +describe('INTERVAL_CHOICES', () => { + it('offers a one-second tick', () => { + // Safe because the loop awaits a whole round before starting the timer, so + // the interval is a gap between rounds rather than a fixed cadence. + expect(INTERVAL_CHOICES).toContain(1000); + }); + + it('is ordered fastest first and holds the default', () => { + expect([...INTERVAL_CHOICES]).toEqual([...INTERVAL_CHOICES].sort((a, b) => a - b)); + expect(INTERVAL_CHOICES).toContain(DEFAULT_INTERVAL_MS); + }); +}); + describe('createWaypoint', () => { it('numbers an unnamed spot rather than leaving it blank', () => { expect(createWaypoint(' ', 3).label).toBe('Spot 3'); diff --git a/src/utils/walkTest.ts b/src/utils/walkTest.ts index 9051eff..336bd94 100644 --- a/src/utils/walkTest.ts +++ b/src/utils/walkTest.ts @@ -60,7 +60,35 @@ export const PROBE_TIMEOUT_MS = 5000; * enough that the tool is not itself the load. */ export const DEFAULT_INTERVAL_MS = 3000; -export const INTERVAL_CHOICES = [2000, 3000, 5000, 10000] as const; +/** + * Gap between rounds, not a fixed cadence: the loop waits for every destination + * in a round to answer or time out before it starts the timer. A round in which + * everything times out therefore takes `PROBE_TIMEOUT_MS`, and the effective + * tick stretches to match rather than requests piling up on a struggling link. + * That is what makes the one-second option safe. + */ +export const INTERVAL_CHOICES = [1000, 2000, 3000, 5000, 10000] as const; + +/** + * Rounds discarded from every timing statistic and every chart at the start of + * a walk. + * + * The first probe to each destination pays for DNS, TCP and TLS, and the second + * often still pays for a cold path somewhere upstream. Left in, those rounds set + * the top of the chart's y-axis for the rest of the session, so the real + * measurements are squashed into a flat line near the bottom and the graph never + * recovers. They are counted, kept, and shown as discarded rather than deleted, + * because a destination that failed during warm-up still failed. + */ +export const WARMUP_ROUNDS = 2; + +/** + * Rounds visible in the live charts. A single bad spike would otherwise hold the + * y-axis at its height for the rest of the walk. The tables and the saved record + * always cover the whole walk; this trims the view, not the data, and the chart + * says which window it is showing. + */ +export const CHART_WINDOW_ROUNDS = 60; /** * Raw samples kept in the saved record. @@ -116,14 +144,6 @@ export const WALK_TARGETS: readonly WalkTarget[] = [ url: 'https://www.netflix.com/robots.txt', note: 'Netflix’s web front door. Playback runs on Open Connect appliances a browser cannot address.', }, - { - id: 'meta', - label: 'Facebook', - category: 'consumer', - host: 'www.facebook.com', - url: 'https://www.facebook.com/robots.txt', - note: 'Meta’s edge, which also fronts Instagram and WhatsApp Web.', - }, { id: 'amazon', label: 'Amazon', @@ -175,6 +195,18 @@ export const WALK_TARGETS: readonly WalkTarget[] = [ url: 'https://slack.com/robots.txt', note: 'Slack’s web edge. The message socket is a separate WebSocket host.', }, + { + id: 'atlassian', + label: 'Atlassian', + category: 'business', + host: 'www.atlassian.com', + url: 'https://www.atlassian.com/robots.txt', + note: + 'Jira and Confluence. Replaced Facebook here because Meta domains sit on most ad and ' + + 'tracker blocklists, so that row failed for people whose network was fine β€” a false alarm ' + + 'is worse than no row. Atlassian also rides a different edge network from anything else ' + + 'in this list, which is worth having.', + }, ]; /** Blank summary, used when a destination has produced nothing yet. Every @@ -202,7 +234,9 @@ export async function probeWalkTarget( options: { round: number; waypointId: string; - connectionSetup: boolean; + /** Whether this is the first probe to this destination that comes back. Set + * by the caller once the outcome is known β€” see `runWalkLoop`. */ + connectionSetup?: boolean; signal?: AbortSignal | undefined; }, ): Promise { @@ -216,7 +250,7 @@ export async function probeWalkTarget( waypointId: options.waypointId, round: options.round, timestamp: Date.now(), - connectionSetup: options.connectionSetup, + connectionSetup: options.connectionSetup === true, }; try { @@ -258,7 +292,11 @@ export interface WalkLoopOptions { export async function runWalkLoop(options: WalkLoopOptions): Promise { const targets = options.targets ?? WALK_TARGETS; const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; - const contacted = new Set(); + // Destinations whose first answer has already come back. The handshake cost + // sits in the first probe that *completes*, not the first one attempted: a + // destination that is unreachable for twenty rounds and then answers pays for + // DNS, TCP and TLS on round twenty-one, and that sample has to be flagged. + const connected = new Set(); let round = 0; while (!options.signal.aborted) { @@ -266,15 +304,17 @@ export async function runWalkLoop(options: WalkLoopOptions): Promise { const waypointId = options.currentWaypointId(); const samples = await Promise.all( - targets.map((target) => { - const connectionSetup = !contacted.has(target.id); - contacted.add(target.id); - return probeWalkTarget(target, { + targets.map(async (target) => { + const sample = await probeWalkTarget(target, { round, waypointId, - connectionSetup, signal: options.signal, }); + if (sample.outcome === 'answered' && !connected.has(target.id)) { + connected.add(target.id); + return { ...sample, connectionSetup: true }; + } + return sample; }), ); @@ -295,12 +335,23 @@ export async function runWalkLoop(options: WalkLoopOptions): Promise { } } -/** Samples that count towards timing statistics: answered, and not the - * connection-setup sample that carries DNS, TCP and TLS inside it. */ +/** + * Whether a sample's timing is usable as a steady-state measurement. + * + * Two exclusions, both about connection setup rather than about the network: + * the opening `WARMUP_ROUNDS` of the walk, and the first probe each destination + * answers, which is where its DNS, TCP and TLS cost lands. Exported because the + * charts must draw exactly what the statistics count, or the picture and the + * table disagree. + */ +export const isSteadyState = (sample: WalkSample): boolean => + sample.outcome === 'answered' && + sample.roundTripMs !== null && + !sample.connectionSetup && + sample.round > WARMUP_ROUNDS; + const steadyStateTimings = (samples: readonly WalkSample[]): number[] => - samples - .filter((s) => s.outcome === 'answered' && !s.connectionSetup && s.roundTripMs !== null) - .map((s) => s.roundTripMs as number); + samples.filter(isSteadyState).map((s) => s.roundTripMs as number); /** * Per-destination statistics over a set of samples. @@ -576,9 +627,11 @@ export function buildWalkResult(input: { reason: 'insufficient-samples', detail: `${stats.label} produced ${stats.summary.answered} answer` + - `${stats.summary.answered === 1 ? '' : 's'}, and the first one against each ` + - `destination is set aside because it includes DNS and TLS setup. At least ` + - `${MIN_SAMPLES_FOR_SUMMARY} later answers are needed before a median is reported.`, + `${stats.summary.answered === 1 ? '' : 's'}, and two of those are set aside: the ` + + `opening ${WARMUP_ROUNDS} rounds of the walk, and the first answer from this ` + + `destination, both of which carry DNS and TLS setup. At least ` + + `${MIN_SAMPLES_FOR_SUMMARY} steady-state answers are needed before a median is ` + + 'reported.', }); } }