diff --git a/Cargo.lock b/Cargo.lock
index 9813e7a..ff30c4b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1318,7 +1318,7 @@ dependencies = [
[[package]]
name = "speedtest-cli"
-version = "0.6.2"
+version = "0.6.3"
dependencies = [
"anyhow",
"bytes",
diff --git a/Cargo.toml b/Cargo.toml
index 51c8805..e54fd56 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "speedtest-cli"
-version = "0.6.2"
+version = "0.6.3"
edition = "2021"
description = "A fast, polished terminal speed test written in Rust"
license-file = "LICENSE"
diff --git a/README.md b/README.md
index 1927316..1665090 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,25 @@
-
speedtest-cli
+# speedtest-cli
-
- Know your speed. Understand your connection.
- A terminal network lab for throughput, latency, bufferbloat, DNS, and connection history.
- Built with Rust. Keyboard driven. Ready for scripts.
-
+> **Know your speed. Understand your connection.**
+
+`speedtest-cli` is a terminal network lab for measuring the connection you actually use—not just a headline Mbps number. It shows throughput, latency under load, jitter, bufferbloat, DNS behavior, stability, and local network performance in one keyboard-friendly tool.
-
-
+
+
- Download
+ Install
•
- Get started
+ First test
•
- Screenshots
+ Commands
•
- User guide
+ License
•
- Get help
+ User guide
@@ -30,36 +28,35 @@
Current development UI, captured from the application's renderer with illustrative test data. These are not measured connection speeds; published releases may differ.
-## Why speedtest-cli?
+## Why use it?
+
+Most speed tests stop at download and upload. `speedtest-cli` helps answer the questions that matter when a video call stutters, a game lags, or Wi-Fi feels slow:
-A fast connection can still struggle with calls or games. speedtest-cli puts throughput beside latency under load, explains the result, and lets you compare it with earlier runs.
+- **Is the connection fast and responsive?** Measure download, upload, idle and loaded latency, jitter, bufferbloat, and a quality score with workload grades.
+- **Where is the bottleneck?** Compare Internet backends, inspect Wi-Fi, or test directly between two machines on your LAN.
+- **Did something change?** Save results locally, browse history, pin a baseline, compare runs, and use offline statistics and insights to spot trends.
+- **Is the network reliable?** Inspect DNS, benchmark UDP or DoH, monitor HTTP availability, and measure ICMP response loss separately.
+- **Do I need a script?** Use plain text, JSON, or CSV output with stable field names and units.
-- **See more than Mbps.** Measure download, upload, idle and loaded latency, jitter, and bufferbloat, with an explained quality score and workload grades.
-- **Find where performance changes.** Cross-check Cloudflare and LibreSpeed, inspect Wi-Fi, or test between two machines on your LAN.
-- **Keep a useful history.** Browse saved runs, spot trends, pin a baseline, and compare six metrics before and after a change.
-- **Investigate DNS and stability.** Inspect resolvers, benchmark UDP or DoH, monitor HTTP availability, and measure ICMP echo response loss separately.
-- **Use it your way.** An offline home dashboard, eight languages, four palettes, and plain text, JSON, and CSV for automation.
+## Install
-## Download
+### Download a release
-Download a native archive from [GitHub Releases](https://github.com/cmdr-chara/speedtest-cli/releases/latest). **Rust is not required** to run a prebuilt binary.
+Prebuilt archives are the easiest option. **Rust is not required** to run one.
-| Your computer | Package |
+| Platform | Download |
| --- | --- |
| Windows · Intel / AMD 64-bit | [speedtest-windows-x86_64.zip](https://github.com/cmdr-chara/speedtest-cli/releases/latest/download/speedtest-windows-x86_64.zip) |
| Linux · Intel / AMD 64-bit | [speedtest-linux-x86_64.tar.gz](https://github.com/cmdr-chara/speedtest-cli/releases/latest/download/speedtest-linux-x86_64.tar.gz) |
| macOS · Apple Silicon | [speedtest-macos-aarch64.tar.gz](https://github.com/cmdr-chara/speedtest-cli/releases/latest/download/speedtest-macos-aarch64.tar.gz) |
| macOS · Intel | [speedtest-macos-x86_64.tar.gz](https://github.com/cmdr-chara/speedtest-cli/releases/latest/download/speedtest-macos-x86_64.tar.gz) |
-Each archive has a matching `.sha256` file on the release page. Extract the archive and launch `speedtest` (`speedtest.exe` on Windows).
+Every release also includes a matching `.sha256` checksum. Extract the archive and run `speedtest` (`speedtest.exe` on Windows).
-Installation commands for Windows, Linux, and macOS
+Install commands
-
-GitHub Releases are the recommended installation method; Rust is not required.
-
-#### Windows x86_64
+#### Windows (PowerShell)
```powershell
Invoke-WebRequest -Uri "https://github.com/cmdr-chara/speedtest-cli/releases/latest/download/speedtest-windows-x86_64.zip" -OutFile speedtest.zip
@@ -67,7 +64,7 @@ Expand-Archive .\speedtest.zip -DestinationPath . -Force
.\speedtest-windows-x86_64\speedtest.exe
```
-#### Linux x86_64
+#### Linux (x86_64)
```bash
curl -L "https://github.com/cmdr-chara/speedtest-cli/releases/latest/download/speedtest-linux-x86_64.tar.gz" -o speedtest.tar.gz
@@ -90,44 +87,45 @@ esac
curl -L "https://github.com/cmdr-chara/speedtest-cli/releases/latest/download/speedtest-macos-${ASSET}.tar.gz" -o speedtest.tar.gz
tar -xzf speedtest.tar.gz
sudo install -m 0755 "speedtest-macos-${ASSET}/speedtest" /usr/local/bin/speedtest
+speedtest
```
-Each packaged release includes SHA-256 checksum files.
-
+See the [latest release](https://github.com/cmdr-chara/speedtest-cli/releases/latest) for all available files and checksums.
-
-Build the current checkout
+### Build from source
-Requires the current stable Rust toolchain. From the repository directory:
+Building requires the current stable Rust toolchain:
```bash
+git clone https://github.com/cmdr-chara/speedtest-cli.git
+cd speedtest-cli
cargo build --release --locked --bin speedtest
-cargo run --release --locked --bin speedtest
+./target/release/speedtest
```
-The executable is written to `target/release/speedtest` (`speedtest.exe` on Windows).
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development and verification instructions.
-
-
-## Get started
+## Run your first test
```bash
-speedtest # Open the dashboard
+speedtest # Open the interactive dashboard
speedtest --run # Start a test immediately
-speedtest --backend librespeed # Open with LibreSpeed selected
-speedtest --plain # Run with plain text output
-speedtest --json # Run with JSON output
+speedtest --plain # Human-readable terminal output
+speedtest --json --no-save # JSON output without writing history
```
-1. Open `speedtest` in a terminal of at least **80 × 24** characters.
-2. Choose **Run Speed Test** and review the backend, duration, and streams.
-3. Start the test, then review throughput, latency, and connection quality.
-4. Open **History** to revisit a run or compare it with an earlier baseline.
+The dashboard is safe to open and browse offline. Network activity starts only when you explicitly start a test or diagnostic. Completed tests are saved locally by default; add `--no-save` when you do not want to write a result.
+
+For the interactive dashboard, use a terminal at least **80 × 24** characters:
-Opening the interactive dashboard reads local history only. Network operations begin when you start them. Completed tests are saved locally unless you use `--no-save`.
+1. Choose **Run Speed Test**.
+2. Review the backend, duration, and stream settings.
+3. Start the test and watch throughput and latency under load.
+4. Open **History** to revisit a result or compare it with a baseline.
+
+### Keyboard controls
| Key | Action |
| --- | --- |
@@ -140,17 +138,37 @@ Opening the interactive dashboard reads local history only. Network operations b
[Full keyboard guide and cockpit behavior →](./docs/usage.md#network-cockpit)
-## See it in action
+## Useful commands
+
+| If you want to… | Use |
+| --- | --- |
+| Run a normal Internet test | `speedtest --run` |
+| Select the LibreSpeed backend | `speedtest --backend librespeed` |
+| Compare Internet backends | `speedtest verify` |
+| Review saved runs | `speedtest history` |
+| See aggregate history statistics | `speedtest stats` |
+| Find trends and unusual results offline | `speedtest insights` |
+| Compare two saved results | `speedtest compare` |
+| Run network diagnostics | `speedtest doctor` |
+| Inspect the Wi-Fi link | `speedtest wifi` |
+| Measure ICMP response loss | `speedtest loss --target 1.1.1.1 --count 50` |
+| Monitor HTTP availability | `speedtest stability --duration 5m` |
+| Benchmark DNS resolvers | `speedtest dns benchmark --protocol doh` |
+| Test two machines on a trusted LAN | `speedtest serve --bind 192.168.1.50:9876`, then `speedtest lan 192.168.1.50:9876` |
-### Watch the connection under load
+`speedtest insights`, history browsing, and the dashboard itself do not need a network connection. They read local data only.
-The live dial shows throughput alongside latency readings as the test progresses.
+### Screenshots and demos
+
+#### Watch a connection under load
+
+The live view shows throughput alongside latency as the test progresses.
-### Explore results and history
+#### Explore results and history
Browse saved tests, pin a baseline, compare it with another run, and open the full result.
@@ -159,16 +177,12 @@ Browse saved tests, pin a baseline, compare it with another run, and open the fu
-View static result and comparison screenshots
-
-Review the exact numbers, quality grade, bufferbloat, and findings in one place.
+View result, history, and comparison screenshots
-Select a saved run and press `b` to pin it. Select another run and press `c` to compare download, upload, latency, jitter, quality, and bufferbloat.
-
@@ -179,37 +193,9 @@ Select a saved run and press `b` to pin it. Select another run and press `c` to
-
-Choose your language and appearance
-
-English, Italian, Spanish, French, German, Portuguese, Simplified Chinese, and Japanese are available in **Settings → Language**, or with `speedtest --language it`.
-
-Choose Terminal (adaptive), Graphite, Light, or Monochrome, and Comfortable or Compact layouts. Settings apply to the current session. Use your terminal's font-size controls to enlarge all text; the interface reflows with the window.
-
-[Language and readability guide →](./docs/usage.md#interface-language-and-text-size)
-
-
-
All gallery images use deterministic fixtures rendered by the current application. [Capture details →](./docs/images/readme/README.md)
-## A network lab in one command
-
-| What you want to learn | Command |
-| --- | --- |
-| Do two Internet backends agree? | `speedtest verify` |
-| Is the local network the bottleneck? | `speedtest serve --bind 192.168.1.50:9876` on one machine, then `speedtest lan 192.168.1.50:9876` on another |
-| What does the network diagnosis show? | `speedtest doctor` |
-| How is the Wi-Fi link configured? | `speedtest wifi` |
-| Are ICMP echo responses being lost? | `speedtest loss --target 1.1.1.1 --count 50` |
-| Does HTTP availability stay consistent? | `speedtest stability --duration 5m` |
-| Which DNS resolvers respond well? | `speedtest dns benchmark --protocol doh` |
-| How has the connection changed? | `speedtest history` / `speedtest stats` / `speedtest compare` |
-
-LAN mode is unauthenticated and unencrypted; bind only on a trusted network and do not expose it to the Internet. HTTP availability and ICMP loss describe different behavior. Quality grades are explained local heuristics, not certifications. [Measurement semantics and limitations →](./docs/usage.md#accuracy-notes)
-
-DNS inspection and benchmarks are read-only. Configuration commands such as `dns set` and `dns optimize` change system settings; preview with `--dry-run` and read the [DNS and recovery guide](./docs/usage.md#dns-suite) first.
-
-## Made for scripts, too
+## Automation
Export a result, then check it offline against your own thresholds:
@@ -218,35 +204,49 @@ speedtest --json --no-save > result.json
speedtest check result.json --min-download 100 --min-upload 20 --max-latency 30
```
-A passing check exits **0**; failed thresholds exit **3**. JSON uses stable field names and units across interface languages. CSV export is available with `--output result.csv --format csv`.
+A passing check exits **0**. A failed threshold exits **3**. CSV is also available:
+
+```bash
+speedtest --output result.csv --format csv
+```
+
+Machine-readable output keeps canonical field names, units, command names, and provider identifiers regardless of interface language. Read the [automation contract](./docs/usage.md#terminal-and-automation-behavior), [offline checks](./docs/usage.md#offline-checks-for-scripts), and [storage guarantees](./docs/usage.md#data-storage) for output streams, errors, cancellation, and concurrent jobs.
-Read the [automation contract](./docs/usage.md#terminal-and-automation-behavior), [offline checks](./docs/usage.md#offline-checks-for-scripts), and [storage guarantees](./docs/usage.md#data-storage) for errors, cancellation, output streams, and concurrent jobs.
+## Safety, privacy, and limitations
-## Documentation and contributing
+- **Local history:** results are stored locally and can be disabled per run with `--no-save`.
+- **Explicit network work:** opening the dashboard, changing sections, reading history, and running insights do not contact a server.
+- **LAN mode:** the LAN server is unauthenticated and unencrypted. Bind it only on a trusted network; never expose it to the Internet.
+- **DNS changes:** inspection and benchmarks are read-only. Commands such as `dns set` and `dns optimize` change system settings—preview them with `--dry-run` first and read the [DNS guide](./docs/usage.md#dns-suite).
+- **Interpretation:** quality grades are local heuristics, not certifications. HTTP availability and ICMP loss measure different things.
+- **Project status:** this is an independent project, not an official Cloudflare or LibreSpeed client.
-- [User guide](./docs/usage.md) — commands, installation, keyboard controls, DNS, result semantics, and storage.
-- [Contributing](./CONTRIBUTING.md) — build, test, and verification workflow.
-- [Architecture](./docs/network-cockpit.md) — cockpit state, services, and rendering.
-- [Verification reports](./docs/verification.md) — recorded checks and remaining limitations.
-- [Report an issue](https://github.com/cmdr-chara/speedtest-cli/issues) — include the command, platform, and relevant output.
+## Languages and appearance
-speedtest-cli is an independent project, not an official Cloudflare or LibreSpeed client.
+The interface supports English, Italian, Spanish, French, German, Portuguese, Simplified Chinese, and Japanese. Choose a language in **Settings → Language** or start with, for example, `speedtest --language it`.
-## License
+Choose Terminal (adaptive), Graphite, Light, or Monochrome palettes, plus Comfortable or Compact layouts. The interface reflows with the terminal window; use your terminal's own font-size controls to enlarge text.
+
+[Language and readability guide →](./docs/usage.md#interface-language-and-text-size)
+
+## Documentation and help
+
+- [User guide](./docs/usage.md) — installation, commands, keyboard controls, DNS, result semantics, and storage.
+- [Network cockpit architecture](./docs/network-cockpit.md) — state, services, and rendering.
+- [Verification reports](./docs/verification.md) — recorded checks and known limitations.
+- [Contributing](./CONTRIBUTING.md) — build, test, and contribution workflow.
+- [Report an issue](https://github.com/cmdr-chara/speedtest-cli/issues) — include the command, platform, and relevant output.
-[speedtest-cli Source Available License 1.0](./LICENSE).
+## License in plain English
-Personal use and internal business use are permitted. Redistribution must include
-corresponding source under the same terms. Modified versions operated for remote
-users must offer those users their corresponding source. Selling copies, bundling
-into paid products, or providing paid services based on the tool requires separate
-written permission. See LICENSE for the definitions and full conditions.
+The current repository is distributed under the [speedtest-cli Source Available License 1.0](./LICENSE). Here is the short version:
-This is a custom source-available license, not AGPL or an OSI-approved open-source
-license. It applies from the revision introducing it; it does not revoke MIT rights
-for previously published material. The existing v0.6.0 release and its downloads
-remain under MIT. The package version is still 0.6.0; identify the source revision
-as well as the version when checking licensing.
+- You may use, study, modify, and share the software without a fee.
+- Personal, educational, research, and internal business use are allowed.
+- If you distribute the software or a modified version, you must provide the complete corresponding source under the same terms and preserve the required notices.
+- If you run a modified version for people over a network, you must offer those users the corresponding source for free while that version is operated.
+- You may not sell it, bundle it into a paid product, or provide a paid remote diagnostic or measurement service using it without separate written permission.
+- Private, undistributed modifications do not require source disclosure.
+- Material that was previously released under MIT—including the published v0.6.0 release—keeps its MIT permissions. See the historical MIT notice in [LICENSE](./LICENSE).
-Request commercial permission through [GitHub](https://github.com/cmdr-chara/speedtest-cli/issues).
-Do not include confidential or personal information in a public request.
+This is a **custom source-available license**. It is **not** the AGPL and is **not an OSI-approved open-source license**. The bullets above are only a convenience summary; the full [license text](./LICENSE) controls. For commercial permission, open an [issue](https://github.com/cmdr-chara/speedtest-cli/issues) without including confidential or personal information.
diff --git a/docs/localization.md b/docs/localization.md
index 8035e48..73c596b 100644
--- a/docs/localization.md
+++ b/docs/localization.md
@@ -106,6 +106,9 @@ template slots. The same words used as server names remain data. Ping-change
sentences have complete templates in all eight catalogs. Result quality labels and
finding/anomaly titles are translated before being combined with their values or
severity labels, so formatting does not prevent a catalog lookup.
+Offline history insights use the same bounded-template rule for path/time summaries:
+backend IDs, server hosts, timestamps, counts and numeric evidence are captured as data
+and never sent through translation lookup.
Do not extend this into unconstrained string replacement over saved/user data.
```bash
diff --git a/docs/network-cockpit.md b/docs/network-cockpit.md
index 803a66f..e8f4b97 100644
--- a/docs/network-cockpit.md
+++ b/docs/network-cockpit.md
@@ -62,6 +62,12 @@ canonical metric deltas, including optional quality and bufferbloat evidence.
Baseline and comparison snapshots remain local to the session and never save data
or start network work.
+Statistics also computes an offline `InsightsReport` for each backend/server path. It
+keeps LAN and Internet populations separate, preserves missing evidence as unavailable,
+and adds interpolated distributions, direction-aware trends, and conservative UTC
+time-of-day comparisons. This analysis is derived from the already-loaded history; it
+does not probe a server, refresh DNS, or write a new result.
+
## Lifecycle and navigation
The root Home page is never popped. Opening a child pushes a page; Back restores the
diff --git a/docs/usage.md b/docs/usage.md
index 3c32f9d..ef03cdd 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -380,9 +380,26 @@ speedtest history --json
speedtest stats
speedtest stats --days 90
speedtest stats --json
+
+speedtest insights
+speedtest insights --days 90 --scope internet
+speedtest insights --backend cloudflare --json
```
History/statistics include median/best throughput, latency statistics, quality context, S-tier counts, trend detection, a Unicode throughput sparkline, and latest-run anomaly detection against prior saved results.
+Anomaly detection requires at least five earlier runs on the same backend/server path;
+it does not flag a provider or server switch as a connection regression.
+
+`insights` is a deeper, offline report for saved history. It keeps each backend/server
+path in its own population, reports interpolated p10/median/p95/max distributions for
+download, upload, idle latency, jitter, loaded latency, and quality, and gives a
+direction-aware trend when at least six samples are available. `--scope internet` and
+`--scope lan` prevent unlike measurements from being compared; `--backend` is
+case-insensitive. The time-of-day comparison is intentionally conservative: each window
+needs at least three usable download samples spread across two distinct UTC dates.
+Missing analysis or partial loaded-latency samples remain unavailable rather than being
+treated as zero. JSON output has `schema_version: 1` and is intended for dashboards and
+automation; it never starts network activity or changes saved data.
The cockpit comparison includes download, upload, latency, jitter, quality score,
and bufferbloat, with before/after values, changes, and explicit better/worse labels.
diff --git a/src/bin/speedtest.rs b/src/bin/speedtest.rs
index 50514cb..b7c4762 100644
--- a/src/bin/speedtest.rs
+++ b/src/bin/speedtest.rs
@@ -11,8 +11,8 @@ use speedtest_cli::{
CheckArgs, Cli, ColorMode, Command, CompareArgs, DnsArgs, DnsBenchmarkArgs,
DnsBenchmarkProfileArg, DnsCommand, DnsListArgs, DnsOptimizeArgs, DnsProtocolArg,
DnsResetArgs, DnsRollbackArgs, DnsSetArgs, DnsShowArgs, DnsTestArgs, DoctorArgs,
- HistoryArgs, InternetBackendArg, LanArgs, LossArgs, ServeArgs, StabilityArgs, StatsArgs,
- VerifyArgs, WifiArgs,
+ HistoryArgs, InsightsArgs, InsightsScopeArg, InternetBackendArg, LanArgs, LossArgs,
+ ServeArgs, StabilityArgs, StatsArgs, VerifyArgs, WifiArgs,
},
compare::{self, CompareResult},
dns::{self, BenchmarkProfile, DnsBenchmarkResult, DnsProviderBenchmark},
@@ -20,7 +20,9 @@ use speedtest_cli::{
doctor::{self, DoctorReport},
engine::{cloudflare::CloudflareEngine, internet::InternetEngine, EngineConfig, EngineEvent},
history::{self, HistorySummary},
- i18n, lan, loss,
+ i18n,
+ insights::{self, InsightsReport},
+ lan, loss,
model::TestResult,
output, runtime,
session::TestOptions,
@@ -162,6 +164,7 @@ async fn dispatch(mut cli: Cli) -> Result<()> {
}
Some(Command::History(args)) => run_history(args),
Some(Command::Stats(args)) => run_stats(args),
+ Some(Command::Insights(args)) => run_insights(args),
Some(Command::Dns(args)) => run_dns(args).await,
Some(Command::Compare(args)) => run_compare(args),
Some(Command::Doctor(args)) => run_doctor(args).await,
@@ -326,6 +329,145 @@ fn run_stats(args: StatsArgs) -> Result<()> {
Ok(())
}
+fn run_insights(args: InsightsArgs) -> Result<()> {
+ let history = storage::load_history_since(args.days)?;
+ let selection = history::HistorySelection {
+ scope: match args.scope {
+ InsightsScopeArg::All => None,
+ InsightsScopeArg::Internet => Some(history::HistoryScope::Internet),
+ InsightsScopeArg::Lan => Some(history::HistoryScope::Lan),
+ },
+ backend: args.backend.clone(),
+ };
+ let selected = selection.select(&history);
+ let report = insights::analyze(&selected, args.days);
+ if args.json {
+ println!("{}", serde_json::to_string_pretty(&report)?);
+ } else {
+ print_insights(&report, args.scope, args.backend.as_deref())?;
+ }
+ Ok(())
+}
+
+fn print_insights(
+ report: &InsightsReport,
+ scope: InsightsScopeArg,
+ backend: Option<&str>,
+) -> Result<()> {
+ println!("{}", tr("NETWORK INSIGHTS"));
+ println!(
+ "{}",
+ msg(
+ "OFFLINE HISTORY ANALYSIS · {0} DAYS · {1} RUNS",
+ &[report.period_days.to_string(), report.runs.to_string()]
+ )
+ );
+ println!(
+ "{}",
+ msg(
+ "FILTER: {0} · BACKEND: {1}",
+ &[
+ match scope {
+ InsightsScopeArg::All => "all".to_string(),
+ InsightsScopeArg::Internet => "internet".to_string(),
+ InsightsScopeArg::Lan => "lan".to_string(),
+ },
+ backend.unwrap_or("all").to_string(),
+ ],
+ )
+ );
+ if report.groups.is_empty() {
+ println!();
+ println!(
+ "{}",
+ tr("No matching saved runs. Run a test and keep history enabled to build a baseline.")
+ );
+ return Ok(());
+ }
+ for group in &report.groups {
+ println!();
+ println!(
+ "{}",
+ msg(
+ "PATH {0} · {1} · {2} RUNS",
+ &[
+ group.backend.clone(),
+ group.server_host.clone(),
+ group.runs.to_string(),
+ ],
+ )
+ );
+ println!(
+ "{}",
+ msg(
+ " {0} → {1} UTC · {2} DAYS SAMPLED",
+ &[
+ group.first_timestamp.format("%Y-%m-%d %H:%M").to_string(),
+ group.last_timestamp.format("%Y-%m-%d %H:%M").to_string(),
+ group.sampled_days.to_string(),
+ ],
+ )
+ );
+ for metric in &group.metrics {
+ let Some(distribution) = &metric.distribution else {
+ continue;
+ };
+ let trend = metric.recent_change_percent.map_or_else(
+ || "insufficient data".to_string(),
+ |change| format!("{} {:+.1}%", tr(metric.trend.label()), change),
+ );
+ println!(
+ "{}",
+ msg(
+ " {0} median {1} {2} · p10 {3} · p95 {4} · {5}",
+ &[
+ format!("{:<16}", tr(metric.metric.label())),
+ format!("{:>8}", format_value(distribution.median)),
+ format!("{:<4}", metric.unit),
+ format!("{:>8}", format_value(distribution.p10)),
+ format!("{:>8}", format_value(distribution.p95)),
+ trend,
+ ],
+ )
+ );
+ }
+ if let Some(comparison) = &group.time_of_day_comparison {
+ println!(
+ "{}",
+ msg(
+ " TIME OF DAY {0}:00–{1}:00 UTC is {2}% faster than {3}:00–{4}:00 (repeated days)",
+ &[
+ format!("{:02}", comparison.fastest_start_hour_utc),
+ format!("{:02}", comparison.fastest_start_hour_utc + 6),
+ format!("{:.1}", comparison.download_gap_percent),
+ format!("{:02}", comparison.slowest_start_hour_utc),
+ format!("{:02}", comparison.slowest_start_hour_utc + 6),
+ ],
+ )
+ );
+ } else {
+ println!(
+ "{}",
+ tr(" TIME OF DAY Need at least 3 download samples across 2 days in two time windows.")
+ );
+ }
+ }
+ println!();
+ println!(
+ "{}",
+ tr(" Percentiles are interpolated. Paths, servers, and LAN/Internet populations are never pooled.")
+ );
+ Ok(())
+}
+
+fn format_value(value: f64) -> String {
+ if value >= 1_000.0 {
+ format!("{value:.0}")
+ } else {
+ format!("{value:.1}")
+ }
+}
+
async fn run_dns(args: DnsArgs) -> Result<()> {
match args.command {
DnsCommand::List(args) => run_dns_list(args),
@@ -810,13 +952,10 @@ fn run_compare(args: CompareArgs) -> Result<()> {
}
(None, None) => {
let history = storage::load_history()?;
- if history.len() < 2 {
+ let Some((before, after)) = history::latest_comparable_pair(&history) else {
bail!("compare requires two saved results or explicit BEFORE and AFTER JSON files");
- }
- (
- history[history.len() - 2].clone(),
- history[history.len() - 1].clone(),
- )
+ };
+ (before, after)
}
_ => bail!("supply both BEFORE and AFTER JSON files, or omit both"),
};
diff --git a/src/cli.rs b/src/cli.rs
index 339d6e8..fad541f 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -99,6 +99,8 @@ pub enum Command {
History(HistoryArgs),
/// Summarize saved runs and flag unusual recent performance.
Stats(StatsArgs),
+ /// Analyze saved runs by comparable backend/server path and time of day.
+ Insights(InsightsArgs),
/// Inspect, benchmark, optimize, and configure DNS resolvers.
Dns(DnsArgs),
/// Compare two saved results, or the two most recent history entries.
@@ -174,6 +176,35 @@ pub struct StatsArgs {
pub json: bool,
}
+#[derive(Debug, Clone, Copy, ValueEnum)]
+pub enum InsightsScopeArg {
+ /// Include both Internet and LAN measurements.
+ All,
+ /// Include only Internet measurements.
+ Internet,
+ /// Include only self-hosted LAN measurements.
+ Lan,
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct InsightsArgs {
+ /// Analyze results from the last N days.
+ #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3650))]
+ pub days: u64,
+
+ /// Restrict the report to one backend identifier, case-insensitively.
+ #[arg(long, value_name = "BACKEND")]
+ pub backend: Option,
+
+ /// Separate Internet and LAN populations instead of comparing them.
+ #[arg(long, value_enum, default_value_t = InsightsScopeArg::All)]
+ pub scope: InsightsScopeArg,
+
+ /// Print the versioned report as JSON.
+ #[arg(long)]
+ pub json: bool,
+}
+
#[derive(Debug, Clone, Args)]
pub struct DnsArgs {
#[command(subcommand)]
@@ -481,6 +512,7 @@ impl Cli {
Some(Command::Stability(a)) => a.json,
Some(Command::History(a)) => a.json,
Some(Command::Stats(a)) => a.json,
+ Some(Command::Insights(a)) => a.json,
Some(Command::Compare(a)) => a.json,
Some(Command::Doctor(a)) => a.json,
Some(Command::Loss(a)) => a.json,
diff --git a/src/history.rs b/src/history.rs
index 7bda186..f49f812 100644
--- a/src/history.rs
+++ b/src/history.rs
@@ -37,6 +37,31 @@ pub enum HistoryScope {
Lan,
}
+/// Read-only selection. Identifiers remain canonical, even in localized reports.
+#[derive(Debug, Clone, Default)]
+pub struct HistorySelection {
+ pub scope: Option,
+ pub backend: Option,
+}
+
+impl HistorySelection {
+ pub fn matches(&self, result: &TestResult) -> bool {
+ self.scope.is_none_or(|scope| matches_scope(result, scope))
+ && self
+ .backend
+ .as_ref()
+ .is_none_or(|backend| result.backend.eq_ignore_ascii_case(backend))
+ }
+
+ pub fn select(&self, results: &[TestResult]) -> Vec {
+ results
+ .iter()
+ .filter(|result| self.matches(result))
+ .cloned()
+ .collect()
+ }
+}
+
impl HistoryScope {
pub const fn label(self) -> &'static str {
match self {
@@ -145,38 +170,49 @@ pub fn summarize(results: &[TestResult], period_days: u64) -> Option Option<(TestResult, TestResult)> {
- [false, true]
- .into_iter()
- .filter_map(|lan_scope| {
- let mut scoped = results
- .iter()
- .rev()
- .filter(|result| is_lan(result) == lan_scope);
- let after = scoped.next()?;
- let before = scoped.next()?;
- Some((before.clone(), after.clone()))
- })
- .max_by_key(|(_, after)| after.timestamp)
+ for (after_index, after) in results.iter().enumerate().rev() {
+ let Some(before) = results[..after_index]
+ .iter()
+ .rev()
+ .find(|before| is_lan(before) == is_lan(after))
+ else {
+ continue;
+ };
+ return Some((before.clone(), after.clone()));
+ }
+ None
}
-fn is_lan(result: &TestResult) -> bool {
+pub fn is_lan(result: &TestResult) -> bool {
result.backend.eq_ignore_ascii_case("lan")
}
-fn matches_scope(result: &TestResult, scope: HistoryScope) -> bool {
+pub fn matches_scope(result: &TestResult, scope: HistoryScope) -> bool {
match scope {
HistoryScope::Internet => !is_lan(result),
HistoryScope::Lan => is_lan(result),
}
}
+/// A path is identified by its stable backend ID and server host. Display names
+/// are descriptive and may change between registry responses.
+pub fn same_path(left: &TestResult, right: &TestResult) -> bool {
+ left.backend.eq_ignore_ascii_case(&right.backend)
+ && left.server.host.eq_ignore_ascii_case(&right.server.host)
+}
+
pub fn detect_latest_anomalies(results: &[TestResult]) -> Vec {
if results.len() < 6 {
return Vec::new();
}
-
let latest = results.last().expect("history has at least one result");
- let baseline = &results[..results.len() - 1];
+ let baseline: Vec<_> = results[..results.len() - 1]
+ .iter()
+ .filter(|result| same_path(result, latest))
+ .collect();
+ if baseline.len() < 5 {
+ return Vec::new();
+ }
let baseline_download = median(
&baseline
.iter()
@@ -449,4 +485,42 @@ mod tests {
assert_eq!(before.timestamp, first_wan.timestamp);
assert_eq!(after.timestamp, second_wan.timestamp);
}
+
+ #[test]
+ fn implicit_compare_can_cross_backend_paths_without_crossing_lan_scope() {
+ let first = result(0, 100.0, 20.0, 10.0);
+ let mut different_backend = result(1, 900.0, 90.0, 5.0);
+ different_backend.backend = "librespeed".into();
+ let mut different_server = result(2, 800.0, 80.0, 6.0);
+ different_server.server.host = "other.example".into();
+ let mut matching_latest = result(3, 120.0, 25.0, 9.0);
+ matching_latest.server.name = "Renamed display name".into();
+
+ let (before, after) = latest_comparable_pair(&[
+ first.clone(),
+ different_backend,
+ different_server.clone(),
+ matching_latest.clone(),
+ ])
+ .unwrap();
+ assert_eq!(before.timestamp, different_server.timestamp);
+ assert_eq!(after.timestamp, matching_latest.timestamp);
+ }
+
+ #[test]
+ fn anomalies_do_not_compare_different_backend_or_server_paths() {
+ let mut results: Vec<_> = (0..4)
+ .map(|index| result(index, 800.0, 100.0, 10.0))
+ .collect();
+ let mut other_path = result(5, 800.0, 100.0, 10.0);
+ other_path.backend = "librespeed".into();
+ results.push(other_path);
+ let mut second_other_path = result(6, 800.0, 100.0, 10.0);
+ second_other_path.server.host = "other.example".into();
+ results.push(second_other_path);
+ let latest = result(7, 400.0, 100.0, 10.0);
+ results.push(latest);
+
+ assert!(detect_latest_anomalies(&results).is_empty());
+ }
}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index a711850..d69b5d4 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -701,5 +701,32 @@
"✓ dns returned to automatic configuration and passed post-change resolution.": "✓ DNS auf automatisch gesetzt; Namensauflösung danach erfolgreich.",
"✓ previous dns snapshot restored and verified.": "✓ Vorheriger DNS-Snapshot wiederhergestellt und geprüft.",
"✓ resolver cache flushed and system dns verification passed.": "✓ Resolvercache geleert; System-DNS erfolgreich geprüft.",
- "✓ {0} configured successfully.": "✓ {0} erfolgreich konfiguriert."
+ "✓ {0} configured successfully.": "✓ {0} erfolgreich konfiguriert.",
+ "analyze saved runs by comparable backend/server path and time of day": "Gespeicherte Messungen nach vergleichbarem Backend/Server-Pfad und Tageszeit analysieren",
+ "include both internet and lan measurements.": "Internet- und LAN-Messungen einschließen.",
+ "include only internet measurements.": "Nur Internet-Messungen einschließen.",
+ "include only self-hosted lan measurements.": "Nur selbst gehostete LAN-Messungen einschließen.",
+ "restrict the report to one backend identifier, case-insensitively.": "Bericht auf eine Backend-Kennung ohne Beachtung der Groß-/Kleinschreibung beschränken.",
+ "separate internet and lan populations instead of comparing them.": "Internet- und LAN-Populationen trennen, statt sie zu vergleichen.",
+ "print the versioned report as json.": "Versionierten Bericht als JSON ausgeben.",
+ "network insights": "NETZWERK-ANALYSE",
+ "offline history analysis · {0} days · {1} runs": "OFFLINE-HISTORIENANALYSE · {0} TAGE · {1} MESSUNGEN",
+ "filter: {0} · backend: {1}": "FILTER: {0} · BACKEND: {1}",
+ "no matching saved runs. run a test and keep history enabled to build a baseline.": "Keine passenden gespeicherten Messungen. Test ausführen und Historie aktiviert lassen, um eine Basis zu erstellen.",
+ "path {0} · {1} · {2} runs": "PFAD {0} · {1} · {2} MESSUNGEN",
+ "{0} → {1} utc · {2} days sampled": "{0} → {1} UTC · {2} TAGE ERFASST",
+ "{0} median {1} {2} · p10 {3} · p95 {4} · {5}": "{0} Median {1} {2} · p10 {3} · p95 {4} · {5}",
+ "time of day {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00 (repeated days)": "TAGESZEIT {0}:00–{1}:00 UTC ist {2}% schneller als {3}:00–{4}:00 (wiederholte Tage)",
+ "time of day need at least 3 download samples across 2 days in two time windows.": "TAGESZEIT Mindestens 3 Download-Messungen an 2 Tagen in zwei Zeitfenstern erforderlich.",
+ "percentiles are interpolated. paths, servers, and lan/internet populations are never pooled.": "Perzentile werden interpoliert. Pfade, Server und LAN-/Internet-Populationen werden nie vermischt.",
+ "comparable paths": "VERGLEICHBARE PFADE",
+ "each backend/server path has its own baseline; lan is never pooled with internet.": "Jeder Backend-/Server-Pfad hat eine eigene Basis; LAN wird nie mit Internet vermischt."
+ ,"restrict the report to one backend identifier, case-insensitively": "Bericht auf eine Backend-Kennung ohne Beachtung der Groß-/Kleinschreibung beschränken"
+ ,"separate internet and lan populations instead of comparing them": "Internet- und LAN-Populationen trennen, statt sie zu vergleichen"
+ ,"print the versioned report as json": "Versionierten Bericht als JSON ausgeben"
+ ,"include both internet and lan measurements": "Internet- und LAN-Messungen einschließen"
+ ,"include only internet measurements": "Nur Internet-Messungen einschließen"
+ ,"include only self-hosted lan measurements": "Nur selbst gehostete LAN-Messungen einschließen"
+ ,"{0} · {1} · {2} runs / {3} days": "{0} · {1} · {2} Messungen / {3} Tage"
+ ,"time effect: {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00": "Zeiteffekt: {0}:00–{1}:00 UTC ist {2}% schneller als {3}:00–{4}:00"
}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index c1352ee..5175ab9 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -701,5 +701,32 @@
"✓ dns returned to automatic configuration and passed post-change resolution.": "✓ DNS returned to automatic configuration and passed post-change resolution.",
"✓ previous dns snapshot restored and verified.": "✓ Previous DNS snapshot restored and verified.",
"✓ resolver cache flushed and system dns verification passed.": "✓ Resolver cache flushed and system DNS verification passed.",
- "✓ {0} configured successfully.": "✓ {0} configured successfully."
+ "✓ {0} configured successfully.": "✓ {0} configured successfully.",
+ "analyze saved runs by comparable backend/server path and time of day": "Analyze saved runs by comparable backend/server path and time of day",
+ "include both internet and lan measurements.": "Include both Internet and LAN measurements.",
+ "include only internet measurements.": "Include only Internet measurements.",
+ "include only self-hosted lan measurements.": "Include only self-hosted LAN measurements.",
+ "restrict the report to one backend identifier, case-insensitively.": "Restrict the report to one backend identifier, case-insensitively.",
+ "separate internet and lan populations instead of comparing them.": "Separate Internet and LAN populations instead of comparing them.",
+ "print the versioned report as json.": "Print the versioned report as JSON.",
+ "network insights": "NETWORK INSIGHTS",
+ "offline history analysis · {0} days · {1} runs": "OFFLINE HISTORY ANALYSIS · {0} DAYS · {1} RUNS",
+ "filter: {0} · backend: {1}": "FILTER: {0} · BACKEND: {1}",
+ "no matching saved runs. run a test and keep history enabled to build a baseline.": "No matching saved runs. Run a test and keep history enabled to build a baseline.",
+ "path {0} · {1} · {2} runs": "PATH {0} · {1} · {2} RUNS",
+ "{0} → {1} utc · {2} days sampled": "{0} → {1} UTC · {2} DAYS SAMPLED",
+ "{0} median {1} {2} · p10 {3} · p95 {4} · {5}": "{0} median {1} {2} · p10 {3} · p95 {4} · {5}",
+ "time of day {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00 (repeated days)": "TIME OF DAY {0}:00–{1}:00 UTC is {2}% faster than {3}:00–{4}:00 (repeated days)",
+ "time of day need at least 3 download samples across 2 days in two time windows.": "TIME OF DAY Need at least 3 download samples across 2 days in two time windows.",
+ "percentiles are interpolated. paths, servers, and lan/internet populations are never pooled.": "Percentiles are interpolated. Paths, servers, and LAN/Internet populations are never pooled.",
+ "comparable paths": "COMPARABLE PATHS",
+ "each backend/server path has its own baseline; lan is never pooled with internet.": "Each backend/server path has its own baseline; LAN is never pooled with Internet."
+ ,"restrict the report to one backend identifier, case-insensitively": "Restrict the report to one backend identifier, case-insensitively"
+ ,"separate internet and lan populations instead of comparing them": "Separate Internet and LAN populations instead of comparing them"
+ ,"print the versioned report as json": "Print the versioned report as JSON"
+ ,"include both internet and lan measurements": "Include both Internet and LAN measurements"
+ ,"include only internet measurements": "Include only Internet measurements"
+ ,"include only self-hosted lan measurements": "Include only self-hosted LAN measurements"
+ ,"{0} · {1} · {2} runs / {3} days": "{0} · {1} · {2} runs / {3} days"
+ ,"time effect: {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00": "Time effect: {0}:00–{1}:00 UTC is {2}% faster than {3}:00–{4}:00"
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 70f6fed..7b9495c 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -701,5 +701,32 @@
"✓ dns returned to automatic configuration and passed post-change resolution.": "✓ DNS volvió a configuración automática y pasó la verificación.",
"✓ previous dns snapshot restored and verified.": "✓ Copia DNS anterior restaurada y verificada.",
"✓ resolver cache flushed and system dns verification passed.": "✓ Caché DNS vaciada y verificación del DNS del sistema superada.",
- "✓ {0} configured successfully.": "✓ {0} configurado correctamente."
+ "✓ {0} configured successfully.": "✓ {0} configurado correctamente.",
+ "analyze saved runs by comparable backend/server path and time of day": "Analiza las pruebas guardadas por ruta comparable de backend/servidor y hora del día",
+ "include both internet and lan measurements.": "Incluye mediciones de Internet y LAN.",
+ "include only internet measurements.": "Incluye solo mediciones de Internet.",
+ "include only self-hosted lan measurements.": "Incluye solo mediciones LAN autoalojadas.",
+ "restrict the report to one backend identifier, case-insensitively.": "Limita el informe a un identificador de backend sin distinguir mayúsculas y minúsculas.",
+ "separate internet and lan populations instead of comparing them.": "Separa las poblaciones de Internet y LAN en lugar de compararlas.",
+ "print the versioned report as json.": "Muestra el informe versionado como JSON.",
+ "network insights": "ANÁLISIS DE RED",
+ "offline history analysis · {0} days · {1} runs": "ANÁLISIS OFFLINE DEL HISTORIAL · {0} DÍAS · {1} PRUEBAS",
+ "filter: {0} · backend: {1}": "FILTRO: {0} · BACKEND: {1}",
+ "no matching saved runs. run a test and keep history enabled to build a baseline.": "No hay pruebas guardadas coincidentes. Ejecuta una prueba y conserva el historial para crear una referencia.",
+ "path {0} · {1} · {2} runs": "RUTA {0} · {1} · {2} PRUEBAS",
+ "{0} → {1} utc · {2} days sampled": "{0} → {1} UTC · {2} DÍAS MUESTREADOS",
+ "{0} median {1} {2} · p10 {3} · p95 {4} · {5}": "{0} mediana {1} {2} · p10 {3} · p95 {4} · {5}",
+ "time of day {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00 (repeated days)": "HORA DEL DÍA {0}:00–{1}:00 UTC es un {2}% más rápida que {3}:00–{4}:00 (días repetidos)",
+ "time of day need at least 3 download samples across 2 days in two time windows.": "HORA DEL DÍA Se necesitan al menos 3 muestras de descarga en 2 días y dos franjas horarias.",
+ "percentiles are interpolated. paths, servers, and lan/internet populations are never pooled.": "Los percentiles se interpolan. Las rutas, servidores y poblaciones LAN/Internet nunca se mezclan.",
+ "comparable paths": "RUTAS COMPARABLES",
+ "each backend/server path has its own baseline; lan is never pooled with internet.": "Cada ruta backend/servidor tiene su propia referencia; la LAN nunca se mezcla con Internet."
+ ,"restrict the report to one backend identifier, case-insensitively": "Limita el informe a un identificador de backend sin distinguir mayúsculas y minúsculas"
+ ,"separate internet and lan populations instead of comparing them": "Separa las poblaciones de Internet y LAN en lugar de compararlas"
+ ,"print the versioned report as json": "Muestra el informe versionado como JSON"
+ ,"include both internet and lan measurements": "Incluye mediciones de Internet y LAN"
+ ,"include only internet measurements": "Incluye solo mediciones de Internet"
+ ,"include only self-hosted lan measurements": "Incluye solo mediciones LAN autoalojadas"
+ ,"{0} · {1} · {2} runs / {3} days": "{0} · {1} · {2} pruebas / {3} días"
+ ,"time effect: {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00": "Efecto horario: {0}:00–{1}:00 UTC es un {2}% más rápida que {3}:00–{4}:00"
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 2057c5f..b7eb306 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -701,5 +701,32 @@
"✓ dns returned to automatic configuration and passed post-change resolution.": "✓ DNS rétabli en mode automatique ; résolution vérifiée.",
"✓ previous dns snapshot restored and verified.": "✓ Sauvegarde DNS précédente restaurée et vérifiée.",
"✓ resolver cache flushed and system dns verification passed.": "✓ Cache DNS vidé ; vérification du DNS système réussie.",
- "✓ {0} configured successfully.": "✓ {0} configuré avec succès."
+ "✓ {0} configured successfully.": "✓ {0} configuré avec succès.",
+ "analyze saved runs by comparable backend/server path and time of day": "Analysez les mesures enregistrées par chemin backend/serveur comparable et heure de la journée",
+ "include both internet and lan measurements.": "Inclure les mesures Internet et LAN.",
+ "include only internet measurements.": "Inclure uniquement les mesures Internet.",
+ "include only self-hosted lan measurements.": "Inclure uniquement les mesures LAN auto-hébergées.",
+ "restrict the report to one backend identifier, case-insensitively.": "Limiter le rapport à un identifiant backend, sans distinguer les majuscules.",
+ "separate internet and lan populations instead of comparing them.": "Séparer les populations Internet et LAN au lieu de les comparer.",
+ "print the versioned report as json.": "Afficher le rapport versionné en JSON.",
+ "network insights": "ANALYSE RÉSEAU",
+ "offline history analysis · {0} days · {1} runs": "ANALYSE HORS LIGNE DE L’HISTORIQUE · {0} JOURS · {1} MESURES",
+ "filter: {0} · backend: {1}": "FILTRE : {0} · BACKEND : {1}",
+ "no matching saved runs. run a test and keep history enabled to build a baseline.": "Aucune mesure enregistrée correspondante. Lancez un test en gardant l’historique actif pour créer une référence.",
+ "path {0} · {1} · {2} runs": "CHEMIN {0} · {1} · {2} MESURES",
+ "{0} → {1} utc · {2} days sampled": "{0} → {1} UTC · {2} JOURS ÉCHANTILLONNÉS",
+ "{0} median {1} {2} · p10 {3} · p95 {4} · {5}": "{0} médiane {1} {2} · p10 {3} · p95 {4} · {5}",
+ "time of day {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00 (repeated days)": "HEURE DE LA JOURNÉE {0}:00–{1}:00 UTC est {2}% plus rapide que {3}:00–{4}:00 (jours répétés)",
+ "time of day need at least 3 download samples across 2 days in two time windows.": "HEURE DE LA JOURNÉE Il faut au moins 3 échantillons de téléchargement sur 2 jours et deux créneaux.",
+ "percentiles are interpolated. paths, servers, and lan/internet populations are never pooled.": "Les percentiles sont interpolés. Les chemins, serveurs et populations LAN/Internet ne sont jamais mélangés.",
+ "comparable paths": "CHEMINS COMPARABLES",
+ "each backend/server path has its own baseline; lan is never pooled with internet.": "Chaque chemin backend/serveur a sa propre référence ; le LAN n’est jamais mélangé à Internet."
+ ,"restrict the report to one backend identifier, case-insensitively": "Limiter le rapport à un identifiant backend, sans distinguer les majuscules"
+ ,"separate internet and lan populations instead of comparing them": "Séparer les populations Internet et LAN au lieu de les comparer"
+ ,"print the versioned report as json": "Afficher le rapport versionné en JSON"
+ ,"include both internet and lan measurements": "Inclure les mesures Internet et LAN"
+ ,"include only internet measurements": "Inclure uniquement les mesures Internet"
+ ,"include only self-hosted lan measurements": "Inclure uniquement les mesures LAN auto-hébergées"
+ ,"{0} · {1} · {2} runs / {3} days": "{0} · {1} · {2} mesures / {3} jours"
+ ,"time effect: {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00": "Effet horaire : {0}:00–{1}:00 UTC est {2}% plus rapide que {3}:00–{4}:00"
}
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index 7c27bed..7367da8 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -701,5 +701,32 @@
"✓ dns returned to automatic configuration and passed post-change resolution.": "✓ DNS tornato alla configurazione automatica; risoluzione verificata.",
"✓ previous dns snapshot restored and verified.": "✓ Snapshot DNS precedente ripristinato e verificato.",
"✓ resolver cache flushed and system dns verification passed.": "✓ Cache DNS svuotata; verifica DNS di sistema riuscita.",
- "✓ {0} configured successfully.": "✓ {0} configurato correttamente."
+ "✓ {0} configured successfully.": "✓ {0} configurato correttamente.",
+ "analyze saved runs by comparable backend/server path and time of day": "Analizza i test salvati per percorso backend/server comparabile e fascia oraria",
+ "include both internet and lan measurements.": "Includi le misurazioni Internet e LAN.",
+ "include only internet measurements.": "Includi solo le misurazioni Internet.",
+ "include only self-hosted lan measurements.": "Includi solo le misurazioni LAN autogestite.",
+ "restrict the report to one backend identifier, case-insensitively.": "Limita il report a un identificatore backend, senza distinzione tra maiuscole e minuscole.",
+ "separate internet and lan populations instead of comparing them.": "Separa le popolazioni Internet e LAN invece di confrontarle.",
+ "print the versioned report as json.": "Stampa il report con versione in formato JSON.",
+ "network insights": "INSIGHT DI RETE",
+ "offline history analysis · {0} days · {1} runs": "ANALISI OFFLINE DELLA CRONOLOGIA · {0} GIORNI · {1} TEST",
+ "filter: {0} · backend: {1}": "FILTRO: {0} · BACKEND: {1}",
+ "no matching saved runs. run a test and keep history enabled to build a baseline.": "Nessun test salvato corrispondente. Esegui un test lasciando attiva la cronologia per creare una base di confronto.",
+ "path {0} · {1} · {2} runs": "PERCORSO {0} · {1} · {2} TEST",
+ "{0} → {1} utc · {2} days sampled": "{0} → {1} UTC · {2} GIORNI CAMPIONATI",
+ "{0} median {1} {2} · p10 {3} · p95 {4} · {5}": "{0} mediana {1} {2} · p10 {3} · p95 {4} · {5}",
+ "time of day {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00 (repeated days)": "FASCIA ORARIA {0}:00–{1}:00 UTC è più veloce del {2}% rispetto a {3}:00–{4}:00 (giorni ripetuti)",
+ "time of day need at least 3 download samples across 2 days in two time windows.": "FASCIA ORARIA Servono almeno 3 campioni download su 2 giorni in due finestre orarie.",
+ "percentiles are interpolated. paths, servers, and lan/internet populations are never pooled.": "I percentili sono interpolati. Percorsi, server e popolazioni LAN/Internet non vengono mai mescolati.",
+ "comparable paths": "PERCORSI COMPARABILI",
+ "each backend/server path has its own baseline; lan is never pooled with internet.": "Ogni percorso backend/server ha la propria base; la LAN non viene mai mescolata a Internet."
+ ,"restrict the report to one backend identifier, case-insensitively": "Limita il report a un identificatore backend, senza distinzione tra maiuscole e minuscole"
+ ,"separate internet and lan populations instead of comparing them": "Separa le popolazioni Internet e LAN invece di confrontarle"
+ ,"print the versioned report as json": "Stampa il report con versione in formato JSON"
+ ,"include both internet and lan measurements": "Includi le misurazioni Internet e LAN"
+ ,"include only internet measurements": "Includi solo le misurazioni Internet"
+ ,"include only self-hosted lan measurements": "Includi solo le misurazioni LAN autogestite"
+ ,"{0} · {1} · {2} runs / {3} days": "{0} · {1} · {2} test / {3} giorni"
+ ,"time effect: {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00": "Effetto orario: {0}:00–{1}:00 UTC è più veloce del {2}% rispetto a {3}:00–{4}:00"
}
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 06edf57..4f689d8 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -701,5 +701,32 @@
"✓ dns returned to automatic configuration and passed post-change resolution.": "✓ DNS を自動設定に戻し、変更後の名前解決も確認しました。",
"✓ previous dns snapshot restored and verified.": "✓ 以前の DNS 設定を復元し、確認しました。",
"✓ resolver cache flushed and system dns verification passed.": "✓ DNS キャッシュを消去し、システムの DNS 検証に成功しました。",
- "✓ {0} configured successfully.": "✓ {0} の設定に成功しました。"
+ "✓ {0} configured successfully.": "✓ {0} の設定に成功しました。",
+ "analyze saved runs by comparable backend/server path and time of day": "比較可能なバックエンド/サーバー経路と時間帯ごとに保存済みテストを分析します",
+ "include both internet and lan measurements.": "Internet と LAN の測定を含めます。",
+ "include only internet measurements.": "Internet の測定だけを含めます。",
+ "include only self-hosted lan measurements.": "セルフホスト LAN の測定だけを含めます。",
+ "restrict the report to one backend identifier, case-insensitively.": "大文字と小文字を区別しないバックエンド識別子でレポートを絞ります。",
+ "separate internet and lan populations instead of comparing them.": "比較せずに Internet と LAN の集団を分けます。",
+ "print the versioned report as json.": "バージョン付きレポートを JSON で出力します。",
+ "network insights": "ネットワーク分析",
+ "offline history analysis · {0} days · {1} runs": "オフライン履歴分析 · {0} 日間 · {1} 回",
+ "filter: {0} · backend: {1}": "フィルター: {0} · バックエンド: {1}",
+ "no matching saved runs. run a test and keep history enabled to build a baseline.": "一致する保存済みテストがありません。履歴を有効にしてテストを実行し、基準値を作成してください。",
+ "path {0} · {1} · {2} runs": "経路 {0} · {1} · {2} 回",
+ "{0} → {1} utc · {2} days sampled": "{0} → {1} UTC · {2} 日をサンプル",
+ "{0} median {1} {2} · p10 {3} · p95 {4} · {5}": "{0} 中央値 {1} {2} · p10 {3} · p95 {4} · {5}",
+ "time of day {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00 (repeated days)": "時間帯 {0}:00–{1}:00 UTC は {3}:00–{4}:00 より {2}% 高速(複数日)",
+ "time of day need at least 3 download samples across 2 days in two time windows.": "時間帯 2 日間の 2 つの時間帯で、少なくとも 3 件のダウンロードサンプルが必要です。",
+ "percentiles are interpolated. paths, servers, and lan/internet populations are never pooled.": "パーセンタイルは補間値です。経路、サーバー、LAN/Internet の集団は混在させません。",
+ "comparable paths": "比較可能な経路",
+ "each backend/server path has its own baseline; lan is never pooled with internet.": "各バックエンド/サーバー経路には固有の基準値があり、LAN と Internet は混在させません。"
+ ,"restrict the report to one backend identifier, case-insensitively": "大文字と小文字を区別しないバックエンド識別子でレポートを絞ります"
+ ,"separate internet and lan populations instead of comparing them": "比較せずに Internet と LAN の集団を分けます"
+ ,"print the versioned report as json": "バージョン付きレポートを JSON で出力します"
+ ,"include both internet and lan measurements": "Internet と LAN の測定を含めます"
+ ,"include only internet measurements": "Internet の測定だけを含めます"
+ ,"include only self-hosted lan measurements": "セルフホスト LAN の測定だけを含めます"
+ ,"{0} · {1} · {2} runs / {3} days": "{0} · {1} · {2} 回 / {3} 日"
+ ,"time effect: {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00": "時間帯の影響: {0}:00–{1}:00 UTC は {3}:00–{4}:00 より {2}% 高速"
}
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 24f63ed..9aa4feb 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -701,5 +701,32 @@
"✓ dns returned to automatic configuration and passed post-change resolution.": "✓ DNS voltou ao automático e passou na verificação de resolução.",
"✓ previous dns snapshot restored and verified.": "✓ Estado anterior do DNS restaurado e verificado.",
"✓ resolver cache flushed and system dns verification passed.": "✓ Cache DNS limpo e verificação do sistema aprovada.",
- "✓ {0} configured successfully.": "✓ {0} configurado com sucesso."
+ "✓ {0} configured successfully.": "✓ {0} configurado com sucesso.",
+ "analyze saved runs by comparable backend/server path and time of day": "Analisar testes salvos por caminho comparável de backend/servidor e hora do dia",
+ "include both internet and lan measurements.": "Incluir medições de Internet e LAN.",
+ "include only internet measurements.": "Incluir somente medições de Internet.",
+ "include only self-hosted lan measurements.": "Incluir somente medições de LAN hospedadas localmente.",
+ "restrict the report to one backend identifier, case-insensitively.": "Restringir o relatório a um identificador de backend, sem diferenciar maiúsculas e minúsculas.",
+ "separate internet and lan populations instead of comparing them.": "Separar populações de Internet e LAN em vez de compará-las.",
+ "print the versioned report as json.": "Imprimir o relatório versionado como JSON.",
+ "network insights": "ANÁLISE DE REDE",
+ "offline history analysis · {0} days · {1} runs": "ANÁLISE OFFLINE DO HISTÓRICO · {0} DIAS · {1} TESTES",
+ "filter: {0} · backend: {1}": "FILTRO: {0} · BACKEND: {1}",
+ "no matching saved runs. run a test and keep history enabled to build a baseline.": "Nenhum teste salvo correspondente. Execute um teste mantendo o histórico ativo para criar uma referência.",
+ "path {0} · {1} · {2} runs": "CAMINHO {0} · {1} · {2} TESTES",
+ "{0} → {1} utc · {2} days sampled": "{0} → {1} UTC · {2} DIAS AMOSTRADOS",
+ "{0} median {1} {2} · p10 {3} · p95 {4} · {5}": "{0} mediana {1} {2} · p10 {3} · p95 {4} · {5}",
+ "time of day {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00 (repeated days)": "HORA DO DIA {0}:00–{1}:00 UTC é {2}% mais rápida que {3}:00–{4}:00 (dias repetidos)",
+ "time of day need at least 3 download samples across 2 days in two time windows.": "HORA DO DIA São necessárias 3 amostras de download em 2 dias e duas janelas de horário.",
+ "percentiles are interpolated. paths, servers, and lan/internet populations are never pooled.": "Os percentis são interpolados. Caminhos, servidores e populações LAN/Internet nunca são misturados.",
+ "comparable paths": "CAMINHOS COMPARÁVEIS",
+ "each backend/server path has its own baseline; lan is never pooled with internet.": "Cada caminho backend/servidor tem sua própria referência; a LAN nunca é misturada à Internet."
+ ,"restrict the report to one backend identifier, case-insensitively": "Restringir o relatório a um identificador de backend, sem diferenciar maiúsculas e minúsculas"
+ ,"separate internet and lan populations instead of comparing them": "Separar populações de Internet e LAN em vez de compará-las"
+ ,"print the versioned report as json": "Imprimir o relatório versionado como JSON"
+ ,"include both internet and lan measurements": "Incluir medições de Internet e LAN"
+ ,"include only internet measurements": "Incluir somente medições de Internet"
+ ,"include only self-hosted lan measurements": "Incluir somente medições de LAN hospedadas localmente"
+ ,"{0} · {1} · {2} runs / {3} days": "{0} · {1} · {2} testes / {3} dias"
+ ,"time effect: {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00": "Efeito do horário: {0}:00–{1}:00 UTC é {2}% mais rápido que {3}:00–{4}:00"
}
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 1b646a2..8cb20a7 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -701,5 +701,32 @@
"✓ dns returned to automatic configuration and passed post-change resolution.": "✓ DNS 已恢复自动配置,并通过修改后的解析验证。",
"✓ previous dns snapshot restored and verified.": "✓ 先前 DNS 快照已恢复并验证。",
"✓ resolver cache flushed and system dns verification passed.": "✓ 解析器缓存已清除,系统 DNS 验证通过。",
- "✓ {0} configured successfully.": "✓ {0} 配置成功。"
+ "✓ {0} configured successfully.": "✓ {0} 配置成功。",
+ "analyze saved runs by comparable backend/server path and time of day": "按可比较的后端/服务器路径和时间段分析已保存的测试",
+ "include both internet and lan measurements.": "包含 Internet 和 LAN 测量。",
+ "include only internet measurements.": "仅包含 Internet 测量。",
+ "include only self-hosted lan measurements.": "仅包含自托管 LAN 测量。",
+ "restrict the report to one backend identifier, case-insensitively.": "按不区分大小写的后端标识符限制报告。",
+ "separate internet and lan populations instead of comparing them.": "分开 Internet 和 LAN 数据群体,而不是将它们比较。",
+ "print the versioned report as json.": "以 JSON 输出带版本报告。",
+ "network insights": "网络洞察",
+ "offline history analysis · {0} days · {1} runs": "离线历史分析 · {0} 天 · {1} 次测试",
+ "filter: {0} · backend: {1}": "筛选:{0} · 后端:{1}",
+ "no matching saved runs. run a test and keep history enabled to build a baseline.": "没有匹配的已保存测试。运行测试并保持历史记录,以建立基线。",
+ "path {0} · {1} · {2} runs": "路径 {0} · {1} · {2} 次测试",
+ "{0} → {1} utc · {2} days sampled": "{0} → {1} UTC · 已采样 {2} 天",
+ "{0} median {1} {2} · p10 {3} · p95 {4} · {5}": "{0} 中位数 {1} {2} · p10 {3} · p95 {4} · {5}",
+ "time of day {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00 (repeated days)": "时间段 {0}:00–{1}:00 UTC 比 {3}:00–{4}:00 快 {2}%(重复日期)",
+ "time of day need at least 3 download samples across 2 days in two time windows.": "时间段 需要在 2 天的两个时间窗口中至少有 3 个下载样本。",
+ "percentiles are interpolated. paths, servers, and lan/internet populations are never pooled.": "百分位数使用插值。路径、服务器以及 LAN/Internet 数据群体永不混合。",
+ "comparable paths": "可比较路径",
+ "each backend/server path has its own baseline; lan is never pooled with internet.": "每个后端/服务器路径都有自己的基线;LAN 永不与 Internet 混合。"
+ ,"restrict the report to one backend identifier, case-insensitively": "按不区分大小写的后端标识符限制报告"
+ ,"separate internet and lan populations instead of comparing them": "分开 Internet 和 LAN 数据群体,而不是将它们比较"
+ ,"print the versioned report as json": "以 JSON 输出带版本报告"
+ ,"include both internet and lan measurements": "包含 Internet 和 LAN 测量"
+ ,"include only internet measurements": "仅包含 Internet 测量"
+ ,"include only self-hosted lan measurements": "仅包含自托管 LAN 测量"
+ ,"{0} · {1} · {2} runs / {3} days": "{0} · {1} · {2} 次测试 / {3} 天"
+ ,"time effect: {0}:00–{1}:00 utc is {2}% faster than {3}:00–{4}:00": "时间影响:{0}:00–{1}:00 UTC 比 {3}:00–{4}:00 快 {2}%"
}
diff --git a/src/i18n/tests.rs b/src/i18n/tests.rs
index ce0bbda..e00028a 100644
--- a/src/i18n/tests.rs
+++ b/src/i18n/tests.rs
@@ -197,6 +197,33 @@ fn recognized_narrative_templates_preserve_numeric_evidence() {
}
}
+#[test]
+fn insights_narrative_templates_translate_owned_words_without_translating_paths() {
+ for language in Language::ALL
+ .into_iter()
+ .filter(|language| *language != Language::En)
+ {
+ let path = narrative(language, "cloudflare · speed.example · 2 runs / 3 days");
+ assert!(path.contains("cloudflare"));
+ assert!(path.contains("speed.example"));
+ assert!(path.contains("2"));
+ assert!(path.contains("3"));
+ assert_ne!(path, "cloudflare · speed.example · 2 runs / 3 days");
+
+ let time = narrative(
+ language,
+ "Time effect: 06:00–12:00 UTC is 25.0% faster than 18:00–24:00",
+ );
+ for value in ["06", "12", "25.0", "18", "24"] {
+ assert!(time.contains(value), "{}: {time}", language.code());
+ }
+ assert_ne!(
+ time,
+ "Time effect: 06:00–12:00 UTC is 25.0% faster than 18:00–24:00"
+ );
+ }
+}
+
#[test]
fn render_scope_is_nested_thread_local_and_does_not_change_cli_language() {
let before = cli_language();
diff --git a/src/insights/mod.rs b/src/insights/mod.rs
new file mode 100644
index 0000000..abcb4eb
--- /dev/null
+++ b/src/insights/mod.rs
@@ -0,0 +1,493 @@
+//! Offline connection intelligence. Never pool different backends or server paths.
+use std::collections::{BTreeMap, BTreeSet};
+
+use chrono::{DateTime, Timelike, Utc};
+use serde::Serialize;
+
+use crate::{
+ history::{self, HistoryScope, HistoryTrend},
+ model::TestResult,
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum HistoryMetric {
+ Download,
+ Upload,
+ IdleLatency,
+ Jitter,
+ LoadedLatency,
+ Quality,
+}
+
+impl HistoryMetric {
+ pub const ALL: [Self; 6] = [
+ Self::Download,
+ Self::Upload,
+ Self::IdleLatency,
+ Self::Jitter,
+ Self::LoadedLatency,
+ Self::Quality,
+ ];
+
+ pub const fn label(self) -> &'static str {
+ match self {
+ Self::Download => "Download",
+ Self::Upload => "Upload",
+ Self::IdleLatency => "Idle latency",
+ Self::Jitter => "Jitter",
+ Self::LoadedLatency => "Loaded latency",
+ Self::Quality => "Quality",
+ }
+ }
+
+ pub const fn unit(self) -> &'static str {
+ match self {
+ Self::Download | Self::Upload => "Mbps",
+ Self::Quality => "pts",
+ _ => "ms",
+ }
+ }
+
+ pub const fn higher_is_better(self) -> bool {
+ matches!(self, Self::Download | Self::Upload | Self::Quality)
+ }
+
+ pub fn next(self) -> Self {
+ let index = Self::ALL
+ .iter()
+ .position(|metric| *metric == self)
+ .unwrap_or(0);
+ Self::ALL[(index + 1) % Self::ALL.len()]
+ }
+
+ /// Missing, negative, and non-finite readings are unavailable, never zero.
+ /// Worst loaded latency requires evidence from BOTH transfer directions.
+ pub fn value(self, result: &TestResult) -> Option {
+ let value = match self {
+ Self::Download => Some(result.download.mbps),
+ Self::Upload => Some(result.upload.mbps),
+ Self::IdleLatency => Some(result.latency.idle_ms),
+ Self::Jitter => Some(result.latency.jitter_ms),
+ Self::LoadedLatency => {
+ let download = valid(result.latency.download_loaded_ms)?;
+ let upload = valid(result.latency.upload_loaded_ms)?;
+ Some(download.max(upload))
+ }
+ Self::Quality => result
+ .analysis
+ .as_ref()
+ .filter(|analysis| analysis.quality.score <= 100)
+ .map(|analysis| f64::from(analysis.quality.score)),
+ };
+ valid(value)
+ }
+}
+
+fn valid(value: Option) -> Option {
+ value.filter(|value| value.is_finite() && *value >= 0.0)
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct InsightsReport {
+ pub schema_version: u8,
+ pub period_days: u64,
+ pub runs: usize,
+ pub groups: Vec,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ConnectionInsights {
+ pub backend: String,
+ pub server_host: String,
+ pub scope: HistoryScope,
+ pub runs: usize,
+ pub sampled_days: usize,
+ pub first_timestamp: DateTime,
+ pub last_timestamp: DateTime,
+ pub metrics: Vec,
+ pub time_of_day: Vec,
+ pub time_of_day_comparison: Option,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MetricInsights {
+ pub metric: HistoryMetric,
+ pub unit: &'static str,
+ pub samples: usize,
+ pub distribution: Option,
+ pub trend: HistoryTrend,
+ pub recent_change_percent: Option,
+ pub sparkline: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MetricDistribution {
+ pub min: f64,
+ pub p10: f64,
+ pub median: f64,
+ pub p95: f64,
+ pub max: f64,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct TimeBucket {
+ pub start_hour_utc: u32,
+ pub end_hour_utc: u32,
+ pub runs: usize,
+ pub sampled_days: usize,
+ pub download_samples: usize,
+ pub median_download_mbps: Option,
+ pub median_upload_mbps: Option,
+ pub median_idle_ms: Option,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct TimeOfDayComparison {
+ pub fastest_start_hour_utc: u32,
+ pub slowest_start_hour_utc: u32,
+ pub download_gap_percent: f64,
+}
+
+/// Date filtering and filesystem reads belong to storage/the caller. Sorting is
+/// stable, so timestamp collisions retain their original record order.
+pub fn analyze(results: &[TestResult], period_days: u64) -> InsightsReport {
+ let mut groups: BTreeMap<(String, String), Vec<&TestResult>> = BTreeMap::new();
+ for result in results {
+ groups
+ .entry((
+ result.backend.to_ascii_lowercase(),
+ result.server.host.to_ascii_lowercase(),
+ ))
+ .or_default()
+ .push(result);
+ }
+ let groups = groups
+ .into_iter()
+ .map(|((backend, host), mut results)| {
+ results.sort_by_key(|result| result.timestamp);
+ let metrics = HistoryMetric::ALL
+ .into_iter()
+ .map(|metric| {
+ let values: Vec<_> = results
+ .iter()
+ .filter_map(|result| metric.value(result))
+ .collect();
+ let (trend, recent_change_percent) = trend(&values, metric.higher_is_better());
+ MetricInsights {
+ metric,
+ unit: metric.unit(),
+ samples: values.len(),
+ distribution: distribution(&values),
+ trend,
+ recent_change_percent,
+ sparkline: metric_sparkline(&results, metric, 48),
+ }
+ })
+ .collect();
+ let time_of_day: Vec<_> = (0..4)
+ .map(|slot| {
+ let start = slot * 6;
+ let samples: Vec<_> = results
+ .iter()
+ .copied()
+ .filter(|result| (start..start + 6).contains(&result.timestamp.hour()))
+ .collect();
+ let downloads: Vec<_> = samples
+ .iter()
+ .copied()
+ .filter(|result| HistoryMetric::Download.value(result).is_some())
+ .collect();
+ TimeBucket {
+ start_hour_utc: start,
+ end_hour_utc: start + 6,
+ runs: samples.len(),
+ // Eligibility counts days with usable download evidence, not just records.
+ sampled_days: distinct_days(&downloads),
+ download_samples: downloads.len(),
+ median_download_mbps: metric_median(&samples, HistoryMetric::Download),
+ median_upload_mbps: metric_median(&samples, HistoryMetric::Upload),
+ median_idle_ms: metric_median(&samples, HistoryMetric::IdleLatency),
+ }
+ })
+ .collect();
+ let mut eligible: Vec<_> = time_of_day
+ .iter()
+ .filter(|bucket| bucket.download_samples >= 3 && bucket.sampled_days >= 2)
+ .collect();
+ eligible.sort_by(|left, right| {
+ left.median_download_mbps
+ .unwrap_or(0.0)
+ .total_cmp(&right.median_download_mbps.unwrap_or(0.0))
+ });
+ let time_of_day_comparison = if eligible.len() >= 2 {
+ let slowest = eligible[0];
+ let fastest = eligible[eligible.len() - 1];
+ let fast = fastest.median_download_mbps.unwrap_or(0.0);
+ let slow = slowest.median_download_mbps.unwrap_or(0.0);
+ (fast > 0.0).then(|| TimeOfDayComparison {
+ fastest_start_hour_utc: fastest.start_hour_utc,
+ slowest_start_hour_utc: slowest.start_hour_utc,
+ download_gap_percent: (1.0 - slow / fast) * 100.0,
+ })
+ } else {
+ None
+ };
+ ConnectionInsights {
+ backend,
+ server_host: host,
+ scope: if history::is_lan(results[0]) {
+ HistoryScope::Lan
+ } else {
+ HistoryScope::Internet
+ },
+ runs: results.len(),
+ sampled_days: distinct_days(&results),
+ first_timestamp: results[0].timestamp,
+ last_timestamp: results[results.len() - 1].timestamp,
+ metrics,
+ time_of_day,
+ time_of_day_comparison,
+ }
+ })
+ .collect();
+ InsightsReport {
+ schema_version: 1,
+ period_days,
+ runs: results.len(),
+ groups,
+ }
+}
+
+fn distinct_days(results: &[&TestResult]) -> usize {
+ results
+ .iter()
+ .map(|result| result.timestamp.date_naive())
+ .collect::>()
+ .len()
+}
+
+fn metric_median(results: &[&TestResult], metric: HistoryMetric) -> Option {
+ distribution(
+ &results
+ .iter()
+ .filter_map(|result| metric.value(result))
+ .collect::>(),
+ )
+ .map(|stats| stats.median)
+}
+
+pub fn distribution(values: &[f64]) -> Option {
+ let mut sorted: Vec<_> = values
+ .iter()
+ .copied()
+ .filter(|value| valid(Some(*value)).is_some())
+ .collect();
+ if sorted.is_empty() {
+ return None;
+ }
+ sorted.sort_by(f64::total_cmp);
+ let quantile = |fraction: f64| {
+ let position = (sorted.len() - 1) as f64 * fraction;
+ let lower = sorted[position.floor() as usize];
+ let upper = sorted[position.ceil() as usize];
+ // Interpolate the bounded difference so two very large values do not
+ // overflow when their weighted sum is rounded.
+ lower + (upper - lower) * position.fract()
+ };
+ Some(MetricDistribution {
+ min: sorted[0],
+ p10: quantile(0.10),
+ median: quantile(0.50),
+ p95: quantile(0.95),
+ max: sorted[sorted.len() - 1],
+ })
+}
+
+fn trend(values: &[f64], higher: bool) -> (HistoryTrend, Option) {
+ if values.len() < 6 {
+ return (HistoryTrend::InsufficientData, None);
+ }
+ let middle = values.len() / 2;
+ let before = distribution(&values[..middle]).map_or(0.0, |stats| stats.median);
+ let after = distribution(&values[middle..]).map_or(0.0, |stats| stats.median);
+ if before <= f64::EPSILON {
+ return (HistoryTrend::InsufficientData, None);
+ }
+ let change = (after / before - 1.0) * 100.0;
+ if !change.is_finite() {
+ return (HistoryTrend::InsufficientData, None);
+ }
+ let desirable = if higher { change } else { -change };
+ let trend = if desirable >= 10.0 {
+ HistoryTrend::Improving
+ } else if desirable <= -10.0 {
+ HistoryTrend::Declining
+ } else {
+ HistoryTrend::Stable
+ };
+ (trend, Some(change))
+}
+
+fn metric_sparkline(results: &[&TestResult], metric: HistoryMetric, width: usize) -> String {
+ let values: Vec<_> = results
+ .iter()
+ .skip(results.len().saturating_sub(width))
+ .map(|result| metric.value(result))
+ .collect();
+ let present: Vec<_> = values.iter().copied().flatten().collect();
+ let line = history::sparkline(&present, width);
+ let mut blocks = line.chars();
+ values
+ .iter()
+ .map(|value| {
+ if value.is_some() {
+ blocks.next().unwrap_or('·')
+ } else {
+ '·'
+ }
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::{Datelike, Duration, TimeZone};
+
+ fn result(day: i64, hour: u32, download: f64) -> TestResult {
+ let mut result: TestResult =
+ serde_json::from_str(include_str!("../../tests/fixtures/result.json")).unwrap();
+ result.timestamp =
+ Utc.with_ymd_and_hms(2026, 9, 1, hour, 0, 0).unwrap() + Duration::days(day);
+ result.download.mbps = download;
+ result
+ }
+
+ #[test]
+ fn empty_report_has_a_versioned_stable_shape() {
+ let report = analyze(&[], 30);
+ assert_eq!(
+ serde_json::to_value(report).unwrap(),
+ serde_json::json!({
+ "schema_version": 1, "period_days": 30, "runs": 0, "groups": []
+ })
+ );
+ }
+
+ #[test]
+ fn keeps_backends_servers_and_lan_in_separate_populations() {
+ let first = result(0, 9, 100.0);
+ let mut other_server = first.clone();
+ other_server.server.host = "another.test".into();
+ let mut lan = first.clone();
+ lan.backend = "lan".into();
+ lan.download.mbps = 20_000.0;
+ let mut other_backend = first.clone();
+ other_backend.backend = "librespeed".into();
+ let report = analyze(&[first, other_server, lan, other_backend], 30);
+ assert_eq!(report.groups.len(), 4);
+ assert!(report.groups.iter().all(|group| group.runs == 1));
+ assert_eq!(
+ report
+ .groups
+ .iter()
+ .find(|group| group.backend == "lan")
+ .unwrap()
+ .scope,
+ HistoryScope::Lan
+ );
+ }
+
+ #[test]
+ fn orders_history_before_computing_direction_aware_trends() {
+ let mut results: Vec<_> = (0..6)
+ .map(|day| result(day, 9, if day < 3 { 100.0 } else { 150.0 }))
+ .collect();
+ for (index, result) in results.iter_mut().enumerate() {
+ result.latency.idle_ms = if index < 3 { 10.0 } else { 30.0 };
+ }
+ results.reverse();
+ let report = analyze(&results, 30);
+ let group = &report.groups[0];
+ assert!(group.first_timestamp < group.last_timestamp);
+ assert_eq!(group.sampled_days, 6);
+ assert_eq!(group.metrics[0].trend, HistoryTrend::Improving);
+ assert_eq!(group.metrics[2].trend, HistoryTrend::Declining);
+ assert_eq!(group.metrics[0].recent_change_percent, Some(50.0));
+ }
+
+ #[test]
+ fn missing_invalid_and_partial_coverage_never_become_zeroes() {
+ let mut results = vec![
+ result(0, 9, 100.0),
+ result(1, 9, -1.0),
+ result(2, 9, f64::NAN),
+ ];
+ results[0].latency.download_loaded_ms = None;
+ results[1].latency.upload_loaded_ms = Some(f64::INFINITY);
+ let report = analyze(&results, 30);
+ let metrics = &report.groups[0].metrics;
+ assert_eq!(metrics[0].samples, 1);
+ assert_eq!(metrics[0].distribution.as_ref().unwrap().median, 100.0);
+ assert_eq!(
+ metrics[0].sparkline.chars().skip(1).collect::(),
+ "··"
+ );
+ assert_eq!(metrics[4].samples, 1);
+ assert_eq!(metrics[5].samples, 0);
+ assert!(metrics[5].distribution.is_none());
+ assert_eq!(metrics[5].sparkline, "···");
+ }
+
+ #[test]
+ fn quantiles_are_interpolated_and_do_not_overflow() {
+ let stats = distribution(&[0.0, 10.0, 20.0, 30.0, 40.0]).unwrap();
+ assert_eq!(stats.p10, 4.0);
+ assert_eq!(stats.median, 20.0);
+ assert!((stats.p95 - 38.0).abs() < 0.0001);
+ assert!(distribution(&[f64::MAX, f64::MAX])
+ .unwrap()
+ .median
+ .is_finite());
+ assert!(distribution(&[]).is_none());
+ }
+
+ #[test]
+ fn time_of_day_requires_repeated_evidence_on_multiple_days() {
+ let mut results = Vec::new();
+ for day in 0..3 {
+ results.push(result(day, 9, 100.0));
+ results.push(result(day, 19, 50.0));
+ }
+ let report = analyze(&results, 30);
+ let group = &report.groups[0];
+ let comparison = group.time_of_day_comparison.as_ref().unwrap();
+ assert_eq!(comparison.fastest_start_hour_utc, 6);
+ assert_eq!(comparison.slowest_start_hour_utc, 18);
+ assert_eq!(comparison.download_gap_percent, 50.0);
+ assert_eq!(group.time_of_day[0].median_download_mbps, None);
+ assert_eq!(group.time_of_day[1].sampled_days, 3);
+ for result in &mut results {
+ result.timestamp = result.timestamp - Duration::days((result.timestamp.day0()) as i64);
+ }
+ assert!(analyze(&results, 30).groups[0]
+ .time_of_day_comparison
+ .is_none());
+ }
+
+ #[test]
+ fn zero_baseline_and_single_runs_do_not_invent_a_trend() {
+ let results: Vec<_> = (0..6)
+ .map(|day| result(day, 9, if day < 3 { 0.0 } else { 100.0 }))
+ .collect();
+ assert_eq!(
+ analyze(&results, 30).groups[0].metrics[0].trend,
+ HistoryTrend::InsufficientData
+ );
+ assert_eq!(
+ analyze(&results[..1], 30).groups[0].metrics[0].recent_change_percent,
+ None
+ );
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 0acf9fb..cb01df2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -6,6 +6,7 @@ pub mod dns_custom;
pub mod doctor;
pub mod engine;
pub mod history;
+pub mod insights;
pub mod lan;
pub mod loss;
pub mod model;
diff --git a/src/tui/cockpit/services.rs b/src/tui/cockpit/services.rs
index ad0d595..8eb07d3 100644
--- a/src/tui/cockpit/services.rs
+++ b/src/tui/cockpit/services.rs
@@ -4,7 +4,10 @@ use std::{process::Stdio, time::Duration};
use anyhow::{bail, Context, Result};
use tokio::{io::AsyncReadExt, process::Command};
-use crate::{compare, history, model::TestResult, output, runtime, session::TestOptions, storage};
+use crate::{
+ compare, history, insights::InsightsReport, model::TestResult, output, runtime,
+ session::TestOptions, storage,
+};
pub(super) const HISTORY_DAYS: u64 = 30;
const REPORT_LIMIT: usize = 256 * 1024;
@@ -14,6 +17,7 @@ pub(super) struct Archive {
pub results: Vec,
pub summary: Option,
pub comparison: Option,
+ pub insights: InsightsReport,
}
/// A comparison keeps the source evidence alongside the existing domain analysis.
@@ -68,14 +72,14 @@ impl Archive {
pub fn from_results(results: Vec) -> Self {
let summary = history::summarize(&results, HISTORY_DAYS);
- let comparison = results
- .len()
- .checked_sub(2)
- .map(|index| ComparedRuns::new(&results[index], &results[index + 1]));
+ let comparison = history::latest_comparable_pair(&results)
+ .map(|(before, after)| ComparedRuns::new(&before, &after));
+ let insights = crate::insights::analyze(&results, HISTORY_DAYS);
Self {
results,
summary,
comparison,
+ insights,
}
}
diff --git a/src/tui/cockpit/tests.rs b/src/tui/cockpit/tests.rs
index 837279b..745d2fe 100644
--- a/src/tui/cockpit/tests.rs
+++ b/src/tui/cockpit/tests.rs
@@ -1058,7 +1058,7 @@ fn comfortable_metrics_are_large_and_compact_values_remain_exact() {
let mut app = app();
app.result = Some(result());
app.push(Screen::Results);
- let text = render(&mut app, 120, 38).0;
+ let text = render(&mut app, 214, 52).0;
assert!(
text.contains("███"),
"five-row metric digits must be visible"
@@ -1163,6 +1163,31 @@ fn short_summaries_keep_controls_nearby_and_long_results_remain_scrollable() {
assert!(text.lines().nth(50).unwrap().contains("quit"));
}
+#[test]
+fn statistics_explains_comparable_paths_without_pooling_lan_samples() {
+ let mut app = app();
+ let mut internet = result();
+ internet.backend = "cloudflare".into();
+ internet.server.host = "speed.cloudflare.com".into();
+ internet.download.mbps = 100.0;
+ let mut later = internet.clone();
+ later.timestamp += chrono::Duration::days(1);
+ later.download.mbps = 120.0;
+ let mut lan = internet.clone();
+ lan.backend = "lan".into();
+ lan.download.mbps = 20_000.0;
+ app.set_history(Ok(Archive::from_results(vec![internet, later, lan])));
+ app.push(Screen::Statistics);
+
+ let text = render(&mut app, 120, 38).0;
+ assert!(text.contains("COMPARABLE PATHS"), "{text}");
+ assert!(text.contains("speed.cloudflare.com"), "{text}");
+ assert!(
+ !text.contains("20000.0"),
+ "LAN sample leaked into Internet stats: {text}"
+ );
+}
+
#[test]
fn sparse_history_keeps_details_near_rows_and_long_history_uses_the_viewport() {
let mut app = app();
diff --git a/src/tui/cockpit/view.rs b/src/tui/cockpit/view.rs
index c85085b..bcf093b 100644
--- a/src/tui/cockpit/view.rs
+++ b/src/tui/cockpit/view.rs
@@ -1148,6 +1148,7 @@ fn statistics(frame: &mut Frame, app: &mut Cockpit, t: Theme, area: Rect) -> u16
let data: Vec<_> = archive
.results
.iter()
+ .filter(|result| crate::history::matches_scope(result, summary.scope))
.rev()
.take(60)
.collect::>()
@@ -1245,6 +1246,63 @@ fn statistics(frame: &mut Frame, app: &mut Cockpit, t: Theme, area: Rect) -> u16
t.base().fg(t.warning),
));
}
+ let comparable_paths: Vec<_> = archive
+ .insights
+ .groups
+ .iter()
+ .filter(|group| group.scope == summary.scope)
+ .collect();
+ if !comparable_paths.is_empty() {
+ lines.extend([
+ Line::default(),
+ Line::styled(ui("COMPARABLE PATHS"), t.strong()),
+ Line::styled(
+ ui("Each backend/server path has its own baseline; LAN is never pooled with Internet."),
+ t.muted(),
+ ),
+ ]);
+ for group in comparable_paths.iter().take(6) {
+ lines.push(Line::styled(
+ ui(format!(
+ "{} · {} · {} runs / {} days",
+ single(&group.backend),
+ single(&group.server_host),
+ group.runs,
+ group.sampled_days
+ )),
+ t.focus(),
+ ));
+ let metrics: Vec<_> = group
+ .metrics
+ .iter()
+ .filter_map(|metric| {
+ metric.distribution.as_ref().map(|distribution| {
+ format!(
+ "{} {:.1} {} (p95 {:.1})",
+ ui(metric.metric.label()),
+ distribution.median,
+ metric.unit,
+ distribution.p95
+ )
+ })
+ })
+ .take(4)
+ .collect();
+ if !metrics.is_empty() {
+ lines.push(Line::from(ui(metrics.join(" · "))));
+ }
+ if let Some(time) = &group.time_of_day_comparison {
+ lines.push(Line::from(ui(format!(
+ "Time effect: {:02}:00–{:02}:00 UTC is {:.1}% faster than {:02}:00–{:02}:00",
+ time.fastest_start_hour_utc,
+ time.fastest_start_hour_utc + 6,
+ time.download_gap_percent,
+ time.slowest_start_hour_utc,
+ time.slowest_start_hour_utc + 6
+ ))));
+ }
+ }
+ }
let used = scroll(frame, app, t, parts[1], lines);
parts[1].y - top + used.saturating_add(1).min(parts[1].height)
}
diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs
index dffb971..a5116fc 100644
--- a/tests/cli_contract.rs
+++ b/tests/cli_contract.rs
@@ -42,6 +42,7 @@ fn help_is_discoverable_uncolored_and_documents_units() {
assert!(result.status.success());
let text = String::from_utf8(result.stdout).unwrap();
assert!(text.contains("check"));
+ assert!(text.contains("insights"));
assert!(text.contains("Examples:"));
assert!(text.contains("--run"));
assert!(text.contains("Mbps"));
@@ -49,6 +50,37 @@ fn help_is_discoverable_uncolored_and_documents_units() {
assert!(result.stderr.is_empty());
}
+#[test]
+fn insights_is_offline_and_returns_a_versioned_empty_report() {
+ let result = run(&["insights", "--json"], None);
+ assert!(
+ result.status.success(),
+ "{}",
+ String::from_utf8_lossy(&result.stderr)
+ );
+ assert!(result.stderr.is_empty());
+ let report: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap();
+ assert_eq!(report["schema_version"], 1);
+ assert_eq!(report["period_days"], 30);
+ assert_eq!(report["runs"], 0);
+ assert_eq!(report["groups"], serde_json::json!([]));
+}
+
+#[test]
+fn insights_json_is_language_invariant() {
+ let mut baseline = None;
+ for language in speedtest_cli::i18n::Language::ALL {
+ let result = run(&["insights", "--json", "--language", language.code()], None);
+ assert!(result.status.success());
+ assert!(result.stderr.is_empty());
+ if let Some(expected) = &baseline {
+ assert_eq!(&result.stdout, expected);
+ } else {
+ baseline = Some(result.stdout);
+ }
+ }
+}
+
#[test]
fn check_pass_and_failure_are_json_with_distinct_exit_codes() {
for (minimum, code, passed) in [("100", 0, true), ("101", 3, false)] {