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.

Latest release - CI - License: Source Available + CI status + Source Available License 1.0

- 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.

Animated speedtest-cli walkthrough from the dashboard into a live download measurement and back

-### 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

Completed result showing download, upload, latency, jitter, quality, and diagnostic findings

-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. -

Saved run history with throughput and quality columns and a preview of the selected result

@@ -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)] {