Skip to main content

netspeed_cli/formatter/
dashboard.rs

1//! Dashboard output formatting — rich single-screen summary with gauges and sparklines.
2
3use crate::common;
4use crate::error::Error;
5use crate::grades::{self, LetterGrade};
6use crate::profiles::UserProfile;
7use crate::terminal;
8use crate::theme::{Colors, Theme};
9use crate::types::TestResult;
10use owo_colors::OwoColorize;
11
12pub struct Summary {
13    pub dl_mbps: f64,
14    pub dl_peak_mbps: f64,
15    pub dl_bytes: u64,
16    pub dl_duration: f64,
17    pub ul_mbps: f64,
18    pub ul_peak_mbps: f64,
19    pub ul_bytes: u64,
20    pub ul_duration: f64,
21    pub elapsed: std::time::Duration,
22    pub profile: UserProfile,
23    pub theme: Theme,
24}
25
26/// Round up to the nearest visually clean scale breakpoint (Mb/s).
27fn gauge_scale(peak_mbps: f64) -> f64 {
28    const BREAKPOINTS: &[f64] = &[50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0];
29    BREAKPOINTS
30        .iter()
31        .copied()
32        .find(|&b| b >= peak_mbps * 1.1)
33        .unwrap_or(peak_mbps * 1.1)
34}
35
36fn render_sparkline_from_samples(samples: &[f64], width: usize) -> String {
37    if samples.len() < 2 {
38        return String::new();
39    }
40    let chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
41    let len = width.max(2);
42    let min = samples.iter().cloned().reduce(f64::min).unwrap_or(0.0);
43    let max = samples.iter().cloned().reduce(f64::max).unwrap_or(1.0);
44    let range = max - min;
45    if range < 0.001 {
46        return chars[4].to_string().repeat(len);
47    }
48    let step = if samples.len() > len {
49        samples.len() / len
50    } else {
51        1
52    };
53    (0..len)
54        .map(|i| {
55            let idx = ((i * step) + (step / 2)).min(samples.len() - 1);
56            let norm = ((samples[idx] - min) / range).clamp(0.0, 1.0);
57            let ci = (norm * (chars.len() - 1) as f64).round() as usize;
58            chars[ci.clamp(0, chars.len() - 1)]
59        })
60        .collect()
61}
62
63fn speed_trend(samples: &[f64]) -> &'static str {
64    if samples.len() < 6 {
65        return "→";
66    }
67    let n = samples.len();
68    let recent: f64 = samples[n - 2..].iter().copied().sum::<f64>() / 2.0;
69    let older: f64 = samples[n - 5..n - 2].iter().copied().sum::<f64>() / 3.0;
70    let ratio = recent / older.max(0.01);
71    if ratio > 1.05 {
72        "↑"
73    } else if ratio < 0.95 {
74        "↓"
75    } else {
76        "→"
77    }
78}
79
80/// Renders the boxed header panel with grade, score, and quality description.
81pub fn boxed_header(grade: &LetterGrade, nc: bool, theme: Theme, term_w: usize) -> String {
82    let box_w = term_w.min(80);
83    // inner_w: space between │ chars (box_w - 2 leading spaces - 2 border chars)
84    let inner_w = box_w.saturating_sub(4);
85    // content_w: usable space inside padding (inner_w - 2 leading spaces - 2 trailing spaces)
86    let content_w = inner_w.saturating_sub(4);
87
88    let score = grade.score() as u32;
89    let grade_str = grade.as_str();
90    let desc_str = grade.description();
91    let left_text = format!("◉ NETSPEED  ·  {desc_str}");
92    let right_text = format!("{grade_str} · {score}");
93
94    // Visible widths (all BMP narrow chars, char count == display width)
95    let left_vis = left_text.chars().count();
96    let right_vis = right_text.chars().count();
97    let padding = content_w.saturating_sub(left_vis + right_vis);
98
99    let top = format!("  ┌{}┐", "─".repeat(inner_w));
100    let bot = format!("  └{}┘", "─".repeat(inner_w));
101
102    if nc {
103        let mid = format!("  │  {left_text}{}{}  │", " ".repeat(padding), right_text);
104        format!("{top}\n{mid}\n{bot}")
105    } else {
106        let left_col = format!(
107            "{}  ·  {}",
108            Colors::bold("◉ NETSPEED", theme),
109            Colors::dimmed(desc_str, theme),
110        );
111        let right_col = format!(
112            "{}{}",
113            grade.color_str(nc, theme),
114            Colors::dimmed(&format!(" · {score}"), theme),
115        );
116        let top_col = format!("  ┌{}┐", "─".repeat(inner_w).dimmed());
117        let bot_col = format!("  └{}┘", "─".repeat(inner_w).dimmed());
118        let mid = format!("  │  {left_col}{}{}  │", " ".repeat(padding), right_col);
119        format!("{top_col}\n{mid}\n{bot_col}")
120    }
121}
122
123/// Two-line speed block: gauge + hero value on line 1, sparkline + trend on line 2.
124fn speed_block(
125    dir: &str,
126    label: &str,
127    speed_mbps: f64,
128    peak_mbps: f64,
129    max_mbps: f64,
130    theme: Theme,
131    gauge_w: usize,
132    sparkline: &str,
133    trend: &str,
134    nc: bool,
135) -> String {
136    let pct = (speed_mbps / max_mbps).clamp(0.0, 1.0);
137    let filled = (pct * gauge_w as f64).round() as usize;
138    let bar = format!(
139        "{}{}",
140        "█".repeat(filled),
141        "░".repeat(gauge_w.saturating_sub(filled))
142    );
143
144    let speed_str = if speed_mbps < 1000.0 {
145        format!("{speed_mbps:.1} Mb/s")
146    } else {
147        format!("{:.2} Gb/s", speed_mbps / 1000.0)
148    };
149    let peak_str = if peak_mbps < 1000.0 {
150        format!("peak {peak_mbps:.0}")
151    } else {
152        format!("peak {:.1}G", peak_mbps / 1000.0)
153    };
154
155    // Prefix before the bar: "  {dir(1)}  {label(10)}  " = 17 visible chars.
156    // Compute from plain text so the indent is the same in both nc and color paths.
157    let indent = " ".repeat(2 + dir.chars().count() + 2 + 10 + 2);
158
159    if nc {
160        let line1 = format!("  {dir}  {label:<10}  {bar}  {speed_str}   {peak_str}");
161        if sparkline.is_empty() {
162            line1
163        } else {
164            format!("{line1}\n{indent}{sparkline}   {trend}")
165        }
166    } else {
167        let bar_col = if pct >= 0.7 {
168            Colors::good(&bar, theme)
169        } else if pct >= 0.4 {
170            Colors::warn(&bar, theme)
171        } else {
172            Colors::bad(&bar, theme)
173        };
174        let speed_col = if pct >= 0.7 {
175            Colors::good(&speed_str, theme)
176        } else if pct >= 0.4 {
177            Colors::warn(&speed_str, theme)
178        } else {
179            Colors::bad(&speed_str, theme)
180        };
181        let dir_col = Colors::muted(dir, theme);
182        // Pad the plain label to 10 visible chars BEFORE colorizing so ANSI bytes
183        // don't confuse Rust's format-string width specifier.
184        let label_col = Colors::dimmed(&format!("{label:<10}"), theme);
185        let peak_col = Colors::dimmed(&peak_str, theme);
186        let trend_col = Colors::dimmed(trend, theme);
187
188        let line1 = format!("  {dir_col}  {label_col}  {bar_col}  {speed_col}   {peak_col}");
189
190        if sparkline.is_empty() {
191            line1
192        } else {
193            let spark_col = if dir == "↓" {
194                Colors::info(sparkline, theme)
195            } else {
196                Colors::good(sparkline, theme)
197            };
198            format!("{line1}\n{indent}{spark_col}   {trend_col}")
199        }
200    }
201}
202
203/// Combined single-line latency + jitter + packet loss row.
204fn latency_row(
205    ping_ms: f64,
206    jitter: Option<f64>,
207    packet_loss: Option<f64>,
208    nc: bool,
209    theme: Theme,
210) -> String {
211    let gauge_w: usize = 12;
212    let max_ping = 100.0;
213    let pct = (ping_ms / max_ping).clamp(0.0, 1.0);
214    let filled = ((1.0 - pct) * gauge_w as f64).round() as usize;
215    let bar = format!(
216        "{}{}",
217        "█".repeat(filled),
218        "░".repeat(gauge_w.saturating_sub(filled))
219    );
220
221    let bar_col = if ping_ms <= 20.0 {
222        Colors::good(&bar, theme)
223    } else if ping_ms <= 50.0 {
224        Colors::warn(&bar, theme)
225    } else {
226        Colors::bad(&bar, theme)
227    };
228
229    let ping_str = format!("{ping_ms:.1} ms");
230    let ping_col = if ping_ms <= 20.0 {
231        Colors::good(&ping_str, theme)
232    } else if ping_ms <= 50.0 {
233        Colors::warn(&ping_str, theme)
234    } else {
235        Colors::bad(&ping_str, theme)
236    };
237
238    let mut parts = if nc {
239        format!("  ◈  Latency    {bar}  {ping_str}")
240    } else {
241        let lbl = Colors::muted("◈", theme);
242        format!(
243            "  {lbl}  {}    {bar_col}  {ping_col}",
244            Colors::dimmed("Latency", theme)
245        )
246    };
247
248    if let Some(j) = jitter {
249        let j_str = format!("{j:.1} ms");
250        if nc {
251            parts.push_str(&format!("   ◈  Jitter  {j_str}"));
252        } else {
253            let lbl = Colors::muted("◈", theme);
254            parts.push_str(&format!(
255                "   {lbl}  {}  {}",
256                Colors::dimmed("Jitter", theme),
257                Colors::info(&j_str, theme),
258            ));
259        }
260    }
261
262    if let Some(loss) = packet_loss {
263        let l_str = format!("{loss:.1}%");
264        if nc {
265            parts.push_str(&format!("   ◈  Loss  {l_str}"));
266        } else {
267            let lbl = Colors::muted("◈", theme);
268            let loss_col = if loss < 0.5 {
269                Colors::good(&l_str, theme)
270            } else if loss < 2.0 {
271                Colors::warn(&l_str, theme)
272            } else {
273                Colors::bad(&l_str, theme)
274            };
275            parts.push_str(&format!(
276                "   {lbl}  {}  {loss_col}",
277                Colors::dimmed("Loss", theme),
278            ));
279        }
280    }
281
282    parts
283}
284
285/// Dashed thin separator line.
286fn thin_separator(w: usize, nc: bool, theme: Theme) -> String {
287    let line = "╌".repeat(w.min(78));
288    if nc {
289        format!("  {line}")
290    } else {
291        format!("  {}", Colors::dimmed(&line, theme))
292    }
293}
294
295/// Inline connection info lines (server + client).
296fn connection_info(result: &TestResult, nc: bool, theme: Theme) -> String {
297    let distance = common::format_distance(result.server.distance);
298    let server_line = if nc {
299        format!(
300            "  ◈  Server    {}  ·  {}  ·  {}",
301            result.server.sponsor, result.server.country, distance
302        )
303    } else {
304        let lbl = Colors::muted("◈", theme);
305        format!(
306            "  {lbl}  {}    {}  {}  {}  {}  {}",
307            Colors::dimmed("Server", theme),
308            result.server.sponsor,
309            Colors::muted("·", theme),
310            Colors::dimmed(&result.server.country, theme),
311            Colors::muted("·", theme),
312            Colors::dimmed(&distance, theme),
313        )
314    };
315
316    let client_line = result.client_ip.as_deref().map(|ip| {
317        if nc {
318            format!("\n  ◈  Client    {ip}")
319        } else {
320            let lbl = Colors::muted("◈", theme);
321            format!(
322                "\n  {lbl}  {}    {}",
323                Colors::dimmed("Client", theme),
324                Colors::dimmed(ip, theme),
325            )
326        }
327    });
328
329    match client_line {
330        Some(cl) => format!("{server_line}{cl}"),
331        None => server_line,
332    }
333}
334
335/// Single-line data transfer summary.
336fn data_summary(summary: &Summary, nc: bool, theme: Theme) -> String {
337    let elapsed = summary.elapsed.as_secs_f64();
338    let total = summary.dl_bytes + summary.ul_bytes;
339
340    let mut parts: Vec<String> = Vec::new();
341
342    if summary.dl_bytes > 0 {
343        let s = format!(
344            "Downloaded {}  in  {:.1}s",
345            common::format_data_size(summary.dl_bytes),
346            summary.dl_duration
347        );
348        parts.push(s);
349    }
350    if summary.ul_bytes > 0 {
351        let s = format!(
352            "Uploaded {}  in  {:.1}s",
353            common::format_data_size(summary.ul_bytes),
354            summary.ul_duration
355        );
356        parts.push(s);
357    }
358    if total > 0 {
359        parts.push(format!("Total {}", common::format_data_size(total)));
360    }
361    parts.push(format!("{elapsed:.1}s"));
362
363    let sep = if nc {
364        "  ·  ".to_string()
365    } else {
366        format!("  {}  ", Colors::muted("·", theme))
367    };
368
369    let joined = parts.join(&sep);
370
371    if nc {
372        format!("  {joined}")
373    } else {
374        format!("  {}", Colors::dimmed(&joined, theme))
375    }
376}
377
378pub fn show(result: &TestResult, summary: &Summary) -> Result<(), Error> {
379    let nc = terminal::no_color() || summary.theme == Theme::Monochrome;
380    let theme = summary.theme;
381    let term_w = common::get_terminal_width().unwrap_or(90) as usize;
382    let gauge_w = (term_w.saturating_sub(52)).clamp(15, 35);
383    let gauge_max = gauge_scale(summary.dl_peak_mbps.max(summary.ul_peak_mbps).max(1.0));
384    let spark_w = (term_w.saturating_sub(54)).clamp(8, 30);
385
386    let overall_grade = grades::grade_overall(
387        result.ping,
388        result.jitter,
389        result.download,
390        result.upload,
391        summary.profile,
392    );
393
394    // ── Header Panel ──────────────────────────────────────────────────────────
395    eprintln!();
396    eprintln!("{}", boxed_header(&overall_grade, nc, theme, term_w));
397    eprintln!();
398
399    // ── Download Block ────────────────────────────────────────────────────────
400    let dl_spark = result
401        .download_samples
402        .as_deref()
403        .map(|s| render_sparkline_from_samples(s, spark_w))
404        .unwrap_or_default();
405    let dl_trend = result
406        .download_samples
407        .as_deref()
408        .map(speed_trend)
409        .unwrap_or("→");
410
411    eprintln!(
412        "{}",
413        speed_block(
414            "↓",
415            "Download",
416            summary.dl_mbps,
417            summary.dl_peak_mbps,
418            gauge_max,
419            theme,
420            gauge_w,
421            &dl_spark,
422            dl_trend,
423            nc,
424        )
425    );
426    eprintln!();
427
428    // ── Upload Block ──────────────────────────────────────────────────────────
429    let ul_spark = result
430        .upload_samples
431        .as_deref()
432        .map(|s| render_sparkline_from_samples(s, spark_w))
433        .unwrap_or_default();
434    let ul_trend = result
435        .upload_samples
436        .as_deref()
437        .map(speed_trend)
438        .unwrap_or("→");
439
440    eprintln!(
441        "{}",
442        speed_block(
443            "↑",
444            "Upload",
445            summary.ul_mbps,
446            summary.ul_peak_mbps,
447            gauge_max,
448            theme,
449            gauge_w,
450            &ul_spark,
451            ul_trend,
452            nc,
453        )
454    );
455    eprintln!();
456
457    // ── Latency Row ───────────────────────────────────────────────────────────
458    if let Some(ping) = result.ping {
459        eprintln!(
460            "{}",
461            latency_row(ping, result.jitter, result.packet_loss, nc, theme)
462        );
463        eprintln!();
464    }
465
466    // ── Connection Info ───────────────────────────────────────────────────────
467    eprintln!("{}", thin_separator(term_w, nc, theme));
468    eprintln!();
469    eprintln!("{}", connection_info(result, nc, theme));
470    eprintln!();
471
472    // ── Data Summary ──────────────────────────────────────────────────────────
473    eprintln!("{}", thin_separator(term_w, nc, theme));
474    eprintln!();
475    eprintln!("{}", data_summary(summary, nc, theme));
476    eprintln!();
477
478    Ok(())
479}