From cc315e62c810b558d1216412eccea986b5fa3e6f Mon Sep 17 00:00:00 2001 From: Perry Dale Date: Sun, 23 Aug 2026 01:39:24 +0000 Subject: [PATCH] Walk & Test: destination cards, warm-up discard, 1s tick; drop the traffic monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes from testing the deployed build on a real network. Facebook out, Atlassian in. Meta domains sit on most ad and tracker blocklists, so that card failed for people whose network was entirely healthy, and a false alarm in a diagnostic is worse than one fewer destination. Atlassian is a business staple that is not commonly filtered and rides a different edge network from anything else in the list, which adds path diversity. The list is now four consumer and six business, and a test pins the blocklist-prone hosts out of it. The card copy and the disclosures name the blocklist case explicitly, because "unanswered" now has one more cause a reader should know about. A card per destination, replacing the table. Each carries the last round trip, answered count, median, jitter and max, and a sparkline in the style of the dashboard's traffic monitor. One to five columns depending on width. 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 the same shape, which defeats the point of showing ten at once. A destination with nothing measured draws no line at all, because a flat trace along the bottom reads as "instant". Warm-up rounds are discarded. The first two rounds, plus each destination's first *answered* probe, carry DNS, TCP and TLS. Left in they pinned the top of every scale near a second for the rest of the session and flattened the real measurements into a line along the bottom. They are counted and shown as discarded, never deleted: a destination that failed during warm-up still failed. Keying the setup flag on the first answer rather than the first attempt also fixes a destination that is silent for twenty rounds and then replies, which pays its handshake on round twenty-one. Charts additionally window to the last 60 rounds so one spike cannot hold the axis for the whole walk. A one-second interval is now offered. Safe because the loop awaits a whole round before starting the timer, so the interval is a gap between rounds and a struggling link stretches it rather than piling requests up. Measured at 1.10s per round with all ten cards charting, and 104 ms click latency during it. The Live Traffic Monitor is removed from the dashboard, with docs/REMOVED-TRAFFIC-MONITOR.md as the reference record: what it showed, how it worked, the `|| 1` latency bug it once had, and why it went. It was passive, so its numbers described NetReady's own activity rather than the network, and it needed a traffic-generating button to look useful. Walk & Test is the active version done properly. Its httpbin.org disclosure was rewritten rather than deleted, because httpbin is still reachable as one of the HTTP Probe's one-click samples β€” the disclosure list has to stay exactly as wide as the app's real reach. One fix the browser run caught: offline, the shared-scale caption read "0 to 10 ms" from a chart-axis default, which is a number no probe produced. The scale is now null when nothing has been measured and the caption says so. Offline now yields zero occurrences of "N ms" anywhere on the page, verified by scanning the whole body rather than the table that no longer exists. --- README.md | 32 +- docs/REMOVED-TRAFFIC-MONITOR.md | 80 ++++ src/components/Dashboard.tsx | 4 - src/components/PrivacySafetyModal.tsx | 12 +- src/components/TrafficMonitor.tsx | 661 -------------------------- src/components/WalkTest.tsx | 624 +++++++++++++++--------- src/types.ts | 12 +- src/utils/walkTest.test.ts | 131 ++++- src/utils/walkTest.ts | 103 +++- 9 files changed, 711 insertions(+), 948 deletions(-) create mode 100644 docs/REMOVED-TRAFFIC-MONITOR.md delete mode 100644 src/components/TrafficMonitor.tsx 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.', }); } }