Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
220 changes: 110 additions & 110 deletions README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions docs/localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/network-cockpit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
157 changes: 148 additions & 9 deletions src/bin/speedtest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@ 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},
dns_custom,
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"),
};
Expand Down
32 changes: 32 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>,

/// 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)]
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading