diff --git a/crates/tinyclient/src/gui/call/health.rs b/crates/tinyclient/src/gui/call/health.rs index 6e8f7546..b7b935fb 100644 --- a/crates/tinyclient/src/gui/call/health.rs +++ b/crates/tinyclient/src/gui/call/health.rs @@ -158,6 +158,42 @@ const WORSE_AFTER: Duration = Duration::from_secs(3); /// And better: a call that went bad has to prove itself for longer. const BETTER_AFTER: Duration = Duration::from_secs(10); +/// Seconds of ping and receive bitrate kept for Call health's time graph. +pub(in crate::gui) const CHART_SECONDS: usize = 60; + +/// One poll on the Call health chart: when it was read, round trip, and what we were receiving. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::gui) struct ChartSample { + pub at: Instant, + pub rtt_ms: u32, + pub recv_kbps: u32, +} + +/// Append one poll to the chart, dropping the oldest past [`CHART_SECONDS`]. +pub(in crate::gui) fn push_chart(samples: &mut VecDeque, stats: &CallStats, at: Instant) { + if samples.len() >= CHART_SECONDS { + samples.pop_front(); + } + samples.push_back(ChartSample { at, rtt_ms: stats.rtt_ms, recv_kbps: stats.receive_kbps }); +} + +/// A full minute of made-up samples around `stats`, for `--shot` so the graph is not empty. +pub(in crate::gui) fn seed_chart(stats: &CallStats, now: Instant) -> VecDeque { + let base_rtt = stats.rtt_ms.max(1); + let base_recv = stats.receive_kbps.max(1); + (0..CHART_SECONDS) + .map(|i| { + let rtt_wobble = ((i * 37) % 11) as i32 - 5; + let recv_wobble = ((i * 53) % 21) as i32 - 10; + ChartSample { + at: now - Duration::from_secs((CHART_SECONDS - 1 - i) as u64), + rtt_ms: (base_rtt as i32 + rtt_wobble).max(1) as u32, + recv_kbps: (base_recv as i32 + recv_wobble * 2).max(1) as u32, + } + }) + .collect() +} + /// The link over the last several seconds, fed each poll, and the grade it has earned. While /// nothing flows nothing is known about loss or jitter, so they and the grade stay as they were. #[derive(Debug, Default)] @@ -463,6 +499,27 @@ mod tests { CallStats { rtt_ms, loss_permille, jitter_ms, link_measured: true, ..CallStats::default() } } + #[test] + fn the_chart_keeps_a_minute_and_shot_seed_fills_it() { + let start = Instant::now(); + let mut samples = VecDeque::new(); + for n in 0..70 { + push_chart( + &mut samples, + &CallStats { rtt_ms: n, receive_kbps: n * 2, ..CallStats::default() }, + start + Duration::from_secs(n as u64), + ); + } + assert_eq!(samples.len(), CHART_SECONDS); + assert_eq!(samples.front().map(|sample| sample.rtt_ms), Some(10)); + assert_eq!(samples.back().map(|sample| sample.rtt_ms), Some(69)); + assert!(samples.back().unwrap().at > samples.front().unwrap().at); + let seeded = seed_chart(&CallStats { rtt_ms: 38, receive_kbps: 182, ..CallStats::default() }, start); + assert_eq!(seeded.len(), CHART_SECONDS); + assert!(seeded.iter().all(|sample| sample.rtt_ms > 0 && sample.recv_kbps > 0)); + assert_eq!(seeded.back().map(|sample| sample.at), Some(start)); + } + #[test] fn a_packet_lost_now_and_then_does_not_move_the_bars() { // One packet of fifty is 20 per mille, and three in one bad second is 60: the old grade diff --git a/crates/tinyclient/src/gui/tiny/mod.rs b/crates/tinyclient/src/gui/tiny/mod.rs index f9a397d6..61fe378c 100644 --- a/crates/tinyclient/src/gui/tiny/mod.rs +++ b/crates/tinyclient/src/gui/tiny/mod.rs @@ -40,7 +40,7 @@ use crate::gui::call::health; use gpui::{prelude::*, Bounds, ClipboardItem, Context, Entity, Focusable, PathPromptOptions, Pixels, Point, Task, Window}; use protocol::{AccountKey, CallStats, Change, Key, Link, Network, Presence, Request, Response, TinyCommunity}; use std::cell::Cell; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io; use std::rc::Rc; use std::time::{Duration, Instant}; @@ -196,6 +196,8 @@ pub struct State { /// The poll before `stats`, so the audio counters (counts over the whole call) can be read /// as what grew in the last second. stats_before: Option, + /// The last minute of ping and receive bitrate for Call health's time graph. + stats_chart: VecDeque, /// The polls over the last several seconds: what the bars and the numbers show. steady: health::Steady, /// Why there are no numbers, when mediad said. @@ -277,6 +279,7 @@ impl State { sliding: None, stats: None, stats_before: None, + stats_chart: VecDeque::new(), steady: health::Steady::default(), stats_note: None, stats_task: None, @@ -881,6 +884,7 @@ impl Shell { if self.tiny_state.stats_task.take().is_some() { self.tiny_state.stats = None; self.tiny_state.stats_before = None; + self.tiny_state.stats_chart.clear(); self.tiny_state.steady = health::Steady::default(); self.tiny_state.stats_note = None; } @@ -892,6 +896,7 @@ impl Shell { self.tiny_state.applied = call; #[cfg(feature = "gui-shot")] if let Some(stats) = crate::gui::call::shot_stats() { + self.tiny_state.stats_chart = health::seed_chart(&stats, Instant::now()); self.tiny_state.stats = Some(stats); return; } @@ -905,6 +910,7 @@ impl Shell { match answer { Ok(Response::CallStats { stats }) => { this.tiny_state.steady.feed(&stats, Instant::now()); + health::push_chart(&mut this.tiny_state.stats_chart, &stats, Instant::now()); this.tiny_state.stats_before = this.tiny_state.stats.replace(stats); this.tiny_state.stats_note = None; } @@ -912,6 +918,7 @@ impl Shell { Ok(Response::Error { detail, .. }) => { this.tiny_state.stats = None; this.tiny_state.stats_before = None; + this.tiny_state.stats_chart.clear(); this.tiny_state.steady = health::Steady::default(); this.tiny_state.stats_note = Some(detail); } diff --git a/crates/tinyclient/src/gui/tiny/panel.rs b/crates/tinyclient/src/gui/tiny/panel.rs index 3d80e829..032c396b 100644 --- a/crates/tinyclient/src/gui/tiny/panel.rs +++ b/crates/tinyclient/src/gui/tiny/panel.rs @@ -2,14 +2,16 @@ //! link is, my microphone and headphones, and the voice settings. While a call is up its second //! line is the state of the call with signal bars and the round trip, and a click on it opens //! Call health: the link graded in words, what to do about it, and the numbers (ping, jitter, -//! loss, bitrate and the path the audio takes). +//! loss, bitrate and the path the audio takes), with a minute of ping and receive on a graph. use super::Float; -use crate::gui::call::health::{self, Grade, Health}; +use crate::gui::call::health::{self, ChartSample, Grade, Health}; use crate::gui::components::{glyph, tip, Avatar}; use crate::gui::state::Shell; use crate::gui::theme::{self, Type}; -use gpui::{div, prelude::*, px, Context, Div, Rgba, SharedString, Stateful}; +use gpui::{ + canvas, div, fill, point, prelude::*, px, size, Bounds, Context, Div, PathBuilder, Rgba, SharedString, Stateful, +}; use protocol::{CallPath, CallState, Presence}; pub(super) const PANEL: f32 = 52.; @@ -65,6 +67,196 @@ pub(super) fn path_words(path: CallPath) -> &'static str { } } +fn path_pill(path: CallPath) -> &'static str { + match path { + CallPath::Udp => "Direct · UDP", + CallPath::Tcp => "Direct · TCP", + CallPath::Relay => "Relayed", + CallPath::Unknown => "Not known yet", + } +} + +fn grade_badge(grade: Grade) -> &'static str { + match grade { + Grade::Unknown => "…", + Grade::Poor => "Poor", + Grade::Fair => "Fair", + Grade::Good => "Good", + Grade::Great => "Excellent", + } +} + +fn meta_row(label: &'static str, value: impl IntoElement) -> Div { + div() + .h(px(26.)) + .flex() + .items_center() + .child(div().flex_1().type_ui().text_color(theme::text_soft()).child(label)) + .child(div().type_ui().font_weight(theme::SEMIBOLD).text_color(theme::text()).child(value)) +} + +fn kpi(label: &'static str, value: String, unit: &'static str, color: Rgba) -> Div { + div() + .flex_1() + .min_w_0() + .px(px(8.)) + .py(px(8.)) + .rounded(px(theme::R_CONTROL)) + .border_1() + .border_color(theme::border()) + .bg(theme::field()) + .child(div().type_micro().font_weight(theme::SEMIBOLD).text_color(theme::text_soft()).child(label)) + .child( + div() + .mt(px(2.)) + .flex() + .items_baseline() + .gap(px(2.)) + .child(div().type_ui().font_weight(theme::SEMIBOLD).text_color(color).child(value)) + .child(div().type_small().font_weight(theme::MEDIUM).text_color(theme::text_soft()).child(unit)), + ) +} + +/// Ping (filled) and receive bitrate over the last minute. `now_*` are the same numbers the +/// KPI strip shows (smoothed ping, current receive), so the footer does not disagree with them. +fn health_chart(samples: Vec, now_rtt: Option, now_recv: u32) -> Div { + let foot = match now_rtt { + Some(rtt) => format!("Now · {rtt} ms · {now_recv} kb/s"), + None => format!("Now · — · {now_recv} kb/s"), + }; + let chart = canvas( + |_, _, _| (), + move |bounds, (), window, _| { + if samples.is_empty() { + return; + } + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + let origin_x = f32::from(bounds.origin.x); + let origin_y = f32::from(bounds.origin.y); + for frac in [0.25_f32, 0.5, 0.75] { + let y = origin_y + height * frac; + window.paint_quad(fill(Bounds::new(point(px(origin_x), px(y)), size(px(width), px(1.))), theme::hairline())); + } + let first = samples.first().map(|sample| sample.at).unwrap_or_else(std::time::Instant::now); + let last_at = samples.last().map(|sample| sample.at).unwrap_or(first); + let span = last_at.duration_since(first).as_secs_f32().max(0.001); + let max_rtt = samples.iter().map(|sample| sample.rtt_ms).max().unwrap_or(1).max(1) as f32; + let max_recv = samples.iter().map(|sample| sample.recv_kbps).max().unwrap_or(1).max(1) as f32; + let x_at = |sample: &ChartSample| origin_x + width * sample.at.duration_since(first).as_secs_f32() / span; + let ping_y = |rtt: u32| { + let t = (rtt as f32 / (max_rtt * 1.2)).clamp(0., 1.); + origin_y + height * (0.42 + 0.5 * (1. - t)) + }; + let recv_y = |kbps: u32| { + let t = (kbps as f32 / (max_recv * 1.2)).clamp(0., 1.); + origin_y + height * (0.06 + 0.3 * (1. - t)) + }; + + let mut area = PathBuilder::fill(); + area.move_to(point(px(x_at(&samples[0])), px(origin_y + height))); + for sample in &samples { + area.line_to(point(px(x_at(sample)), px(ping_y(sample.rtt_ms)))); + } + area.line_to(point(px(x_at(samples.last().unwrap())), px(origin_y + height))); + area.close(); + if let Ok(path) = area.build() { + window.paint_path(path, theme::positive_soft()); + } + + let mut ping = PathBuilder::stroke(px(2.)); + for (index, sample) in samples.iter().enumerate() { + let at = point(px(x_at(sample)), px(ping_y(sample.rtt_ms))); + if index == 0 { + ping.move_to(at); + } else { + ping.line_to(at); + } + } + if let Ok(path) = ping.build() { + window.paint_path(path, theme::positive()); + } + + let mut recv = PathBuilder::stroke(px(1.75)).dash_array(&[px(3.), px(3.)]); + for (index, sample) in samples.iter().enumerate() { + let at = point(px(x_at(sample)), px(recv_y(sample.recv_kbps))); + if index == 0 { + recv.move_to(at); + } else { + recv.line_to(at); + } + } + if let Ok(path) = recv.build() { + window.paint_path(path, theme::accent()); + } + + if let Some(last) = samples.last() { + let cx = x_at(last); + let cy = ping_y(last.rtt_ms); + window.paint_quad(fill( + Bounds::new(point(px(cx - 5.), px(cy - 5.)), size(px(10.), px(10.))), + theme::positive_soft(), + )); + window.paint_quad(fill( + Bounds::new(point(px(cx - 2.5), px(cy - 2.5)), size(px(5.), px(5.))), + theme::positive(), + )); + } + }, + ) + .h(px(92.)) + .w_full(); + + div() + .mb(px(10.)) + .p(px(10.)) + .rounded(px(theme::R_CONTROL)) + .border_1() + .border_color(theme::border()) + .bg(theme::field()) + .child( + div() + .mb(px(6.)) + .flex() + .items_center() + .justify_between() + .child(div().type_micro().font_weight(theme::SEMIBOLD).text_color(theme::text_soft()).child("Last 60s")) + .child( + div() + .flex() + .items_center() + .gap(px(10.)) + .child( + div() + .flex() + .items_center() + .gap(px(5.)) + .child(div().size(px(7.)).rounded_full().bg(theme::positive())) + .child(div().type_micro().text_color(theme::text_soft()).child("Ping")), + ) + .child( + div() + .flex() + .items_center() + .gap(px(5.)) + .child(div().size(px(7.)).rounded_full().bg(theme::accent())) + .child(div().type_micro().text_color(theme::text_soft()).child("Recv")), + ), + ), + ) + .child(chart) + .child( + div() + .mt(px(4.)) + .flex() + .justify_between() + .type_micro() + .text_color(theme::text_soft()) + .child("−60s") + .child(foot), + ) +} + impl Shell { /// The panel, at the very foot of the sidebar on a Tiny server. pub(in crate::gui) fn tiny_user_panel(&self, cx: &mut Context) -> Option
{ @@ -165,34 +357,64 @@ impl Shell { self.tiny_state.stats.as_ref().map(|stats| self.tiny_state.steady.applied(Health::from_polls(before, stats))) } - /// Call health, over the panel: the link graded, what to do about it in words, then the - /// connection's numbers. + /// Call health, over the panel: the link graded, what to do about it in words, the last + /// minute on a graph, key numbers, then the connection's details. pub(super) fn tiny_stats_card(&self) -> Div { - let row = |label: &'static str, value: String| { - div() - .h(px(24.)) - .flex() - .items_center() - .child(div().flex_1().type_ui().text_color(theme::text_soft()).child(label)) - .child(div().type_ui().font_weight(theme::MEDIUM).text_color(theme::text()).child(value)) - }; let room = self.tiny_page_facts().and_then(|page| page.mine.and_then(|id| page.community.rooms.iter().find(|room| room.id == id)).map(|room| (room.name.clone(), room.bitrate()))); let health = self.tiny_health().unwrap_or_default(); let grade = health.grade(); - let mut card = div().w(px(288.)).flex().flex_col().gap(px(2.)).child( + let hints = health.hints(); + let mut card = div().w(px(320.)).flex().flex_col().child( div() - .pb(px(8.)) + .pb(px(10.)) .flex() - .items_center() + .items_start() + .justify_between() .gap(px(8.)) - .child(health::bars(grade, 14.)) - .child(div().flex_1().type_ui().font_weight(theme::SEMIBOLD).text_color(theme::text()).child("Call health")) - .child(div().type_small().text_color(grade.color()).child(grade.words())), + .child( + div() + .min_w_0() + .flex_1() + .child( + div() + .flex() + .items_center() + .gap(px(8.)) + .child(health::bars(grade, 14.)) + .child(div().type_ui().font_weight(theme::SEMIBOLD).text_color(theme::text()).child("Call health")), + ) + .children(hints.iter().take(1).map(|hint| { + div() + .mt(px(6.)) + .flex() + .items_start() + .gap(px(6.)) + .child(div().pt(px(1.)).child(glyph(hint.level.icon(), 12., hint.level.color()))) + .child(div().flex_1().min_w_0().whitespace_normal().type_small().text_color(hint.level.color()).child(hint.words.clone())) + })), + ) + .child( + div() + .flex_none() + .px(px(8.)) + .py(px(3.)) + .rounded_full() + .bg(match grade { + Grade::Great | Grade::Good => theme::positive_soft(), + Grade::Fair => theme::warning_soft(), + Grade::Poor => theme::danger_soft(), + Grade::Unknown => theme::field(), + }) + .type_micro() + .font_weight(theme::SEMIBOLD) + .text_color(grade.color()) + .child(grade_badge(grade)), + ), ); - let hints = health.hints(); - if !hints.is_empty() { + + if hints.len() > 1 { card = card.child( - div().pb(px(8.)).mb(px(4.)).border_b_1().border_color(theme::hairline()).flex().flex_col().gap(px(6.)).children(hints.into_iter().take(3).map(|hint| { + div().pb(px(8.)).mb(px(4.)).border_b_1().border_color(theme::hairline()).flex().flex_col().gap(px(6.)).children(hints.into_iter().skip(1).take(2).map(|hint| { div() .flex() .items_start() @@ -202,19 +424,58 @@ impl Shell { })), ); } - if let Some((name, kbps)) = &room { - card = card.child(row("Channel", name.clone())).child(row("Channel bitrate", format!("{kbps} kb/s"))); - } + match &self.tiny_state.stats { Some(stats) => { + let samples: Vec<_> = self.tiny_state.stats_chart.iter().copied().collect(); + if !samples.is_empty() { + card = card.child(health_chart(samples, health.rtt_ms, stats.receive_kbps)); + } + let known = |measured: bool| if measured { theme::positive() } else { theme::text_soft() }; card = card - .child(row("Ping", format!("{} ms", health.rtt_ms.unwrap_or(0)))) - .child(row("Jitter", format!("{} ms", health.jitter_ms.unwrap_or(0)))) - .child(row("Packet loss", format!("{:.1} %", f32::from(health.loss_permille.unwrap_or(0)) / 10.))) - .child(row("Sending", format!("{} kb/s", stats.send_kbps))) - .child(row("Receiving", format!("{} kb/s", stats.receive_kbps))) - .child(row("Path", path_words(stats.path).to_owned())) - .when(!stats.codec.is_empty(), |card| card.child(row("Codec", stats.codec.clone()))); + .child( + div() + .mb(px(10.)) + .flex() + .gap(px(8.)) + .child(kpi( + "Ping", + health.rtt_ms.map_or_else(|| "—".to_owned(), |ms| ms.to_string()), + "ms", + known(health.rtt_ms.is_some()), + )) + .child(kpi( + "Jitter", + health.jitter_ms.map_or_else(|| "—".to_owned(), |ms| ms.to_string()), + "ms", + known(health.jitter_ms.is_some()), + )) + .child(kpi( + "Loss", + health.loss_permille.map_or_else(|| "—".to_owned(), |n| format!("{:.1}", f32::from(n) / 10.)), + "%", + known(health.loss_permille.is_some()), + )), + ) + .when_some(room.as_ref(), |card, (name, kbps)| { + card.child(meta_row("Channel", name.clone())).child(meta_row("Channel bitrate", format!("{kbps} kb/s"))) + }) + .child(meta_row("Sending", format!("{} kb/s", stats.send_kbps))) + .child(meta_row("Receiving", format!("{} kb/s", stats.receive_kbps))) + .child(meta_row( + "Path", + div() + .px(px(7.)) + .py(px(2.)) + .rounded_full() + .bg(theme::selected()) + .flex() + .items_center() + .gap(px(5.)) + .child(div().size(px(6.)).rounded_full().bg(theme::positive())) + .child(div().type_micro().font_weight(theme::SEMIBOLD).text_color(theme::text()).child(path_pill(stats.path))), + )) + .when(!stats.codec.is_empty(), |card| card.child(meta_row("Codec", stats.codec.clone()))); } None => { let words: SharedString = self.tiny_state.stats_note.clone().unwrap_or_else(|| "Measuring…".to_owned()).into(); @@ -249,5 +510,7 @@ mod tests { assert_eq!(quality(&stats(40, 40)), theme::warning()); assert_eq!(quality(&stats(400, 0)), theme::danger()); assert_eq!(path_words(CallPath::Relay), "Relayed"); + assert_eq!(path_pill(CallPath::Udp), "Direct · UDP"); + assert_eq!(grade_badge(Grade::Great), "Excellent"); } } diff --git a/docs/design/shots/call-health-card.png b/docs/design/shots/call-health-card.png new file mode 100644 index 00000000..7e39c6f6 Binary files /dev/null and b/docs/design/shots/call-health-card.png differ