diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 079325d..e544fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,15 @@ jobs: fi echo "the icon draws: $(stat -c %s /tmp/icon.png) bytes of PNG" + # Cairo draws onto a buffer and the buffer is counted — no display, no window, no screenshot. A chart + # that draws nothing looks exactly like a quiet hour in a running window, which is the one way this + # could ship broken and nobody notice. Proven against a `render` that returns early before it was + # trusted: 0 of 180000 pixels, exit 1. + - name: The chart draws something + run: | + set -euo pipefail + cargo run -q -p flowlight-gui --example chart + - name: A row shows the text it was given run: | set -euo pipefail diff --git a/Cargo.lock b/Cargo.lock index 800bd55..4e8f8b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,7 +540,7 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flowlight-agents" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", "serde", @@ -550,11 +550,11 @@ dependencies = [ [[package]] name = "flowlight-alerts" -version = "0.5.15" +version = "0.6.0" [[package]] name = "flowlight-ask" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", "flowlight-store", @@ -565,14 +565,14 @@ dependencies = [ [[package]] name = "flowlight-common" -version = "0.5.15" +version = "0.6.0" dependencies = [ "aya", ] [[package]] name = "flowlight-daemon" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", "aya", @@ -598,14 +598,14 @@ dependencies = [ [[package]] name = "flowlight-devices" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", ] [[package]] name = "flowlight-ebpf" -version = "0.5.15" +version = "0.6.0" dependencies = [ "aya-ebpf", "flowlight-common", @@ -613,7 +613,7 @@ dependencies = [ [[package]] name = "flowlight-gui" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", "async-channel", @@ -625,14 +625,14 @@ dependencies = [ [[package]] name = "flowlight-owners" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", ] [[package]] name = "flowlight-platform" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", "libc", @@ -640,7 +640,7 @@ dependencies = [ [[package]] name = "flowlight-proxy" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", "flowlight-agents", @@ -656,7 +656,7 @@ dependencies = [ [[package]] name = "flowlight-rules" -version = "0.5.15" +version = "0.6.0" dependencies = [ "serde", "serde_json", @@ -664,7 +664,7 @@ dependencies = [ [[package]] name = "flowlight-store" -version = "0.5.15" +version = "0.6.0" dependencies = [ "anyhow", "flowlight-alerts", @@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "flowlight-text" -version = "0.5.15" +version = "0.6.0" [[package]] name = "foldhash" diff --git a/Cargo.toml b/Cargo.toml index 4b3e59e..02b928b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ default-members = [ ] [workspace.package] -version = "0.5.15" +version = "0.6.0" edition = "2024" license = "GPL-3.0-only" repository = "https://github.com/xinbetween/flowlight-linux" diff --git a/README.md b/README.md index 943b53b..626ecbc 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,19 @@ including an installation from it with a throwaway key, and goes live once a sig [`packaging/apt/README.md`](packaging/apt/README.md) says why that key is not something this code can create for you. Until it is, the commands above have nothing to answer them. +**The window is built on the same design system as the macOS app.** The palette lives in +`crates/flowlight-gui/src/tokens.rs` — the same hexadecimal values, the same semantic roles — and feeds two +consumers: the stylesheet, as generated `@define-color` lines, and the charts, as numbers for Cairo. GTK has +no media query for the colour scheme, so the stylesheet is rebuilt and reloaded when libadwaita says the +appearance changed. Nothing outside that file may name a colour. + +**Charts are drawn rather than depended on.** GTK has no chart widget, so `chart.rs` is a `GtkDrawingArea` +and about forty lines of Cairo — no new dependency on a machine that is already asking the kernel for +permission to read other processes' memory. What came back is drawn upward from a centre line and what went +out downward from it, the way the macOS app does it, so the two directions read apart instead of being summed +into one line that answers neither question. The arithmetic is separate from the drawing and tested, and CI +renders a chart onto a buffer and counts the ink on every push. + **The application icon.** It is one SVG, at `/usr/share/icons/hicolor/scalable/apps/com.xinbetween.Flowlight.svg`, and the desktop draws it through gdk-pixbuf — which recognises the format by sniffing the first bytes of the file. Keep ` i64 { + 60 +} + fn an_hour() -> i64 { 3_600 } @@ -579,6 +593,12 @@ fn handle( &mut store, crate::views::window(now, since), )?)?, + Request::Series { since, buckets } => serde_json::to_string(&crate::views::series( + &mut store, + crate::views::window(now, since), + now, + buckets, + )?)?, Request::Rules => serde_json::to_string(&crate::views::rules(&mut store)?)?, Request::Budget => serde_json::to_string(&crate::views::budget(&mut store, now)?)?, Request::SetBudget { change } => { diff --git a/crates/flowlight-daemon/src/views.rs b/crates/flowlight-daemon/src/views.rs index 15dab36..3fdae42 100644 --- a/crates/flowlight-daemon/src/views.rs +++ b/crates/flowlight-daemon/src/views.rs @@ -1814,6 +1814,77 @@ pub fn simulate( .collect()) } +/// Traffic over time, in buckets, for drawing rather than reading. +/// +/// The one shape a chart needs and a table cannot give: evenly spaced buckets across the whole window, +/// including the empty ones. A series that skips quiet buckets draws a line that lies about when things +/// happened — two requests an hour apart become adjacent points — so the gaps are filled here, where the +/// window's start and the bucket size are both known, rather than guessed at by whoever draws it. +pub fn series(store: &mut Store, since: i64, now: i64, buckets: i64) -> Result { + let buckets = buckets.clamp(2, 600); + let span = (now - since).max(buckets); + // Bucket sizes are whole seconds, and the window is divided to get about as many buckets as asked for. + let width = (span / buckets).max(1); + let first = (since / width) * width; + let last = (now / width) * width; + + let rows = store.series(None, first, last + width, width)?; + let found: std::collections::HashMap = + rows.iter().map(|row| (row.at, row)).collect(); + + let mut points = Vec::new(); + let mut at = first; + while at <= last { + let row = found.get(&at); + points.push(SeriesPoint { + at, + requests: row.map_or(0, |row| row.requests), + bytes: row.map_or(0, |row| row.bytes), + received: row.map_or(0, |row| row.received), + sent: row.map_or(0, |row| row.sent), + }); + at += width; + } + + Ok(SeriesView { + width, + busiest: points.iter().map(|point| point.bytes).max().unwrap_or(0), + total: points.iter().map(|point| point.bytes).sum(), + requests: points.iter().map(|point| point.requests).sum(), + points, + }) +} + +/// Traffic over time. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SeriesView { + /// How many seconds each bucket covers. + pub width: i64, + /// The most bytes in any one bucket, so a drawing has a scale without walking the points twice. + pub busiest: i64, + /// Bytes across the window. + pub total: i64, + /// Requests across the window. + pub requests: i64, + /// Every bucket, including the empty ones. + pub points: Vec, +} + +/// One bucket. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SeriesPoint { + /// Its start, in seconds since the epoch. + pub at: i64, + /// Requests in it. + pub requests: i64, + /// Bytes they carried. + pub bytes: i64, + /// Of those, what came back. + pub received: i64, + /// And what went out. + pub sent: i64, +} + /// What was seen, and what was not. pub fn coverage(store: &mut Store, since: i64) -> Result { let coverage = store.coverage(since)?; diff --git a/crates/flowlight-gui/examples/chart.rs b/crates/flowlight-gui/examples/chart.rs new file mode 100644 index 0000000..2612302 --- /dev/null +++ b/crates/flowlight-gui/examples/chart.rs @@ -0,0 +1,79 @@ +//! Draws a chart onto an image and says whether anything was drawn. +//! +//! No window, no display, no GTK: Cairo draws onto a buffer and the buffer is counted. A chart that draws +//! nothing — a scale that divided by zero, a fill that never closed, a colour that came back as the +//! background — looks exactly like a quiet hour in a running window, which is the one bug a screenshot +//! would not settle. +//! +//! Run with an argument to also write the PNG somewhere and look at it. +use flowlight_gui::chart::{self, Bucket}; +use gtk::cairo; + +fn main() { + // A shape with both directions, a peak, and a quiet stretch — so a chart that only draws one series, or + // only non-empty buckets, fails here rather than on somebody's desktop. + let buckets: Vec = (0..60) + .map(|index| { + let busy = (index as f64 / 7.0).sin().abs(); + Bucket { + at: 1_790_000_000 + index * 60, + received: if (20..26).contains(&index) { + 0 + } else { + (busy * 90_000.0) as i64 + }, + sent: if (20..26).contains(&index) { + 0 + } else { + (busy * 21_000.0) as i64 + }, + } + }) + .collect(); + + for (appearance, dark) in [("light", false), ("dark", true)] { + let Ok(surface) = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 200) else { + println!("FAIL: no image surface"); + std::process::exit(1); + }; + let Ok(context) = cairo::Context::new(&surface) else { + println!("FAIL: no context"); + std::process::exit(1); + }; + chart::render(&context, &buckets, dark, 900.0, 200.0); + drop(context); + + let data = match surface.take_data() { + Ok(data) => data.to_vec(), + Err(err) => { + println!("FAIL: {err}"); + std::process::exit(1); + } + }; + // Anything with alpha in it is ink. The fourth byte of each pixel, without indexing a slice the + // lints would rather nobody indexed. + let inked = data + .iter() + .skip(3) + .step_by(4) + .filter(|alpha| **alpha > 0) + .count(); + let total = 900 * 200; + println!("{appearance}: {inked} of {total} pixels drawn"); + if inked < total / 50 { + println!("FAIL: a chart that draws {inked} pixels is a chart nobody can see"); + std::process::exit(1); + } + + // With a path, the raw pixels go out beside the count so somebody can look at the thing rather + // than at a number. Raw rather than PNG: encoding it would mean a cairo feature this crate does not + // otherwise need, for a debugging convenience. + if let Some(into) = std::env::args().nth(1) { + let path = format!("{into}/chart-{appearance}.rgba"); + if std::fs::write(&path, &data).is_ok() { + println!(" 900x200 BGRA written to {path}"); + } + } + } + println!("OK: the chart draws in both appearances"); +} diff --git a/crates/flowlight-gui/src/chart.rs b/crates/flowlight-gui/src/chart.rs new file mode 100644 index 0000000..17228f6 --- /dev/null +++ b/crates/flowlight-gui/src/chart.rs @@ -0,0 +1,266 @@ +//! Traffic drawn rather than listed. +//! +//! GTK has no chart widget, so this is a `GtkDrawingArea` and Cairo. That is less of a compromise than it +//! sounds: the one chart this window needs is a two-direction area chart, and the arithmetic for it is forty +//! lines. What it buys is no new dependency on a machine that is already asking a kernel for permission to +//! read other processes' memory. +//! +//! The shape follows the macOS app: what came back is drawn upward from a centre line and what went out is +//! drawn downward from it, so the two directions are read apart at a glance instead of being summed into one +//! line that answers neither question. Both are filled under the stroke, because a thin line on its own +//! disappears at the window sizes people actually use. +//! +//! The arithmetic is separate from the drawing and tested. A chart that is wrong is worse than no chart, +//! and "it looked right on my screen" is not a test. + +use crate::tokens; +use gtk::cairo; +use gtk::prelude::*; + +/// One bucket of traffic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bucket { + /// Its start, in seconds since the epoch. + pub at: i64, + /// Bytes that came back. + pub received: i64, + /// Bytes that went out. + pub sent: i64, +} + +/// A round number at or above `largest`, for the top of an axis. +/// +/// An axis labelled 0 and 1,734 bytes makes a reader do arithmetic to compare two charts. One labelled 0 and +/// 2 kB does not. +/// +/// Rounded in the unit it will be *written* in, which is the part worth saying out loud: a ceiling of 100000 +/// is a round number in bytes and reads as "97.7 kB" on the axis, because kilobytes here are 1024 bytes. +/// Rounding to one, two or five times a power of ten within the unit gives a number that is round on the +/// screen, which is where somebody reads it. +pub fn ceiling(largest: i64) -> i64 { + if largest <= 0 { + return 1; + } + // The unit the label will use, from the same thresholds `size` uses. + let unit: i64 = if largest < 1_024 { + 1 + } else if largest < 1_048_576 { + 1_024 + } else { + 1_048_576 + }; + + let mut step = 1_i64; + loop { + for multiple in [1, 2, 5] { + let candidate = step.saturating_mul(multiple).saturating_mul(unit); + if candidate >= largest { + return candidate; + } + } + let Some(next) = step.checked_mul(10) else { + return largest; + }; + step = next; + } +} + +/// Where a bucket sits across the width, from its position in the series. +/// +/// Evenly spaced by index rather than by timestamp, because the series arrives already evenly spaced — the +/// daemon fills the quiet buckets in — and spacing by timestamp as well would only reintroduce rounding. +pub fn across(index: usize, count: usize, width: f64) -> f64 { + if count <= 1 { + return 0.0; + } + width * index as f64 / (count - 1) as f64 +} + +/// How far from the centre line a value reaches, in pixels, never past the half it is given. +pub fn reach(value: i64, top: i64, half: f64) -> f64 { + if top <= 0 || value <= 0 { + return 0.0; + } + let share = value as f64 / top as f64; + share.clamp(0.0, 1.0) * half +} + +/// The chart widget: a drawing area that redraws when it is given new buckets. +pub struct Chart { + /// The widget to put in a window. + pub widget: gtk::DrawingArea, +} + +impl Chart { + /// A chart of nothing, ready to be given buckets. + pub fn new(height: i32) -> Self { + let widget = gtk::DrawingArea::builder() + .content_height(height) + .hexpand(true) + .build(); + widget.add_css_class("fl-chart"); + Self { widget } + } + + /// Draws these buckets, replacing whatever was drawn before. + /// + /// `dark` is passed rather than read here so that one answer from the style manager is used by every + /// drawing in a refresh, instead of each one asking and a redraw straddling an appearance change. + pub fn show(&self, buckets: Vec, dark: bool) { + self.widget.set_draw_func(move |_, context, width, height| { + render(context, &buckets, dark, f64::from(width), f64::from(height)); + }); + self.widget.queue_draw(); + } +} + +/// The drawing itself, on any Cairo context. +/// +/// Public so it can be drawn onto an image surface and looked at without opening a window — which is how +/// this is checked, since a chart that draws nothing is exactly the bug a window makes hardest to notice. +/// +/// Errors from Cairo are swallowed deliberately: a failed stroke means a chart with a missing line, and a +/// window that refuses to draw anything because one stroke failed is worse than the missing line. +pub fn render(context: &cairo::Context, buckets: &[Bucket], dark: bool, width: f64, height: f64) { + let set = |name: &str, alpha: f64| { + let (r, g, b) = tokens::colour(name, dark); + context.set_source_rgba(r, g, b, alpha); + }; + + // Room on the left for the axis labels, and a hair top and bottom so a peak is not clipped. + let left = 52.0; + let plot = (width - left - 8.0).max(1.0); + let top = 10.0; + let floor = (height - 16.0).max(top + 2.0); + let centre = (top + floor) / 2.0; + let half = (floor - centre).max(1.0); + + let largest = buckets + .iter() + .map(|bucket| bucket.received.max(bucket.sent)) + .max() + .unwrap_or(0); + let ceiling = ceiling(largest); + + // The axis: the centre line, and one line at each end of the scale. + context.set_line_width(1.0); + for (y, label) in [ + (centre - half, crate::size(ceiling)), + (centre, "0".to_owned()), + (centre + half, crate::size(ceiling)), + ] { + set("line", 1.0); + context.move_to(left, y.round() + 0.5); + context.line_to(left + plot, y.round() + 0.5); + let _ = context.stroke(); + + set("faint", 1.0); + context.set_font_size(10.0); + let extents = context + .text_extents(&label) + .map(|e| e.width()) + .unwrap_or(0.0); + context.move_to(left - 6.0 - extents, y + 3.0); + let _ = context.show_text(&label); + } + + if buckets.len() < 2 { + set("faint", 1.0); + context.set_font_size(11.0); + context.move_to(left + 8.0, centre - 6.0); + let _ = context.show_text("Not enough yet to draw"); + return; + } + + // Each direction: a filled area from the centre line, then the line on top of it. + for (name, upward) in [("received", true), ("sent", false)] { + let value = |bucket: &Bucket| if upward { bucket.received } else { bucket.sent }; + let y = |bucket: &Bucket| { + let distance = reach(value(bucket), ceiling, half); + if upward { + centre - distance + } else { + centre + distance + } + }; + + context.move_to(left, centre); + for (index, bucket) in buckets.iter().enumerate() { + context.line_to(left + across(index, buckets.len(), plot), y(bucket)); + } + context.line_to(left + plot, centre); + context.close_path(); + set(name, 0.35); + let _ = context.fill(); + + for (index, bucket) in buckets.iter().enumerate() { + let x = left + across(index, buckets.len(), plot); + if index == 0 { + context.move_to(x, y(bucket)); + } else { + context.line_to(x, y(bucket)); + } + } + set(name, 1.0); + context.set_line_width(1.8); + context.set_line_join(cairo::LineJoin::Round); + let _ = context.stroke(); + } + + // The centre line goes on top, so neither fill hides where zero is. + set("line", 1.0); + context.set_line_width(1.0); + context.move_to(left, centre.round() + 0.5); + context.line_to(left + plot, centre.round() + 0.5); + let _ = context.stroke(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_axis_tops_out_at_a_number_somebody_would_say() { + // Round where it is read: on the axis, in the unit the label uses. + assert_eq!(crate::size(ceiling(90_000)), "100.0 kB"); + assert_eq!(crate::size(ceiling(1_734)), "2.0 kB"); + assert_eq!(crate::size(ceiling(600)), "1000 B"); + assert_eq!(crate::size(ceiling(3_000_000)), "5.0 MB"); + // Nothing yet still has a scale, or every value divides by zero. + assert_eq!(ceiling(0), 1); + assert_eq!(ceiling(-5), 1); + } + + #[test] + fn the_ceiling_is_never_below_what_it_has_to_hold() { + for value in [ + 1_i64, 7, 99, 100, 101, 999, 1_000, 1_001, 123_456, 9_999_999, + ] { + assert!( + ceiling(value) >= value, + "{value} does not fit under {}", + ceiling(value) + ); + } + } + + #[test] + fn buckets_spread_across_the_width_and_reach_both_ends() { + assert_eq!(across(0, 5, 100.0), 0.0); + assert_eq!(across(4, 5, 100.0), 100.0); + assert_eq!(across(2, 5, 100.0), 50.0); + // One bucket has nowhere to be but the start, and must not divide by zero getting there. + assert_eq!(across(0, 1, 100.0), 0.0); + } + + #[test] + fn a_value_reaches_its_share_of_the_half_it_is_given() { + assert_eq!(reach(50, 100, 80.0), 40.0); + assert_eq!(reach(100, 100, 80.0), 80.0); + assert_eq!(reach(0, 100, 80.0), 0.0); + // A value past the top is clamped rather than drawn outside the chart. + assert_eq!(reach(400, 100, 80.0), 80.0); + // And nothing divides by a zero scale. + assert_eq!(reach(5, 0, 80.0), 0.0); + } +} diff --git a/crates/flowlight-gui/src/flowlight.css b/crates/flowlight-gui/src/flowlight.css new file mode 100644 index 0000000..6c6f514 --- /dev/null +++ b/crates/flowlight-gui/src/flowlight.css @@ -0,0 +1,66 @@ +/* Flowlight's rules. The colours they refer to are generated from `tokens.rs` and prepended to this file, + * one appearance at a time, because GTK has no media query for the colour scheme. + * + * Nothing here may contain a hexadecimal colour. The palette is the palette. + */ + +/* A card: the one raised surface, a faint top-to-bottom gradient for depth without a shadow, one hairline + * border. Three slightly different rounded rectangles is how a window stops looking like one thing. */ +.fl-card { + background-image: linear-gradient(to bottom, @fl_surface, @fl_surface_deep); + border: 1px solid @fl_line; + border-radius: 12px; +} + +/* A tile of one number. The figure is the content; the label explains it and the trend sits under it. */ +.fl-tile-label { + font-size: 0.82rem; + color: @fl_muted; +} + +.fl-tile-value { + font-size: 1.6rem; + font-weight: 600; + font-feature-settings: "tnum"; +} + +/* The direction tokens, as text and as fills. */ +.fl-received { color: @fl_received; } +.fl-sent { color: @fl_sent; } +.fl-accent { color: @fl_accent; } +.fl-good { color: @fl_good; } +.fl-warning { color: @fl_warning; } +.fl-critical { color: @fl_critical; } +.fl-tool { color: @fl_tool; } +.fl-muted { color: @fl_muted; } +.fl-faint { color: @fl_faint; } + +/* A pill: a status marked by a tinted capsule with a border, never by colour alone — which is also why + * every pill in this window carries a word. */ +.fl-pill { + font-size: 0.78rem; + font-weight: 700; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid alpha(currentColor, 0.32); + background-color: alpha(currentColor, 0.14); +} + +/* The chart surface. The drawing is done in Cairo; this is the frame around it. */ +.fl-chart { + background-color: @fl_surface; + border: 1px solid @fl_line; + border-radius: 12px; +} + +.fl-chart-legend { + font-size: 0.82rem; + color: @fl_muted; +} + +/* A section heading above a group of cards. */ +.fl-section { + font-size: 0.82rem; + font-weight: 700; + color: @fl_muted; +} diff --git a/crates/flowlight-gui/src/lib.rs b/crates/flowlight-gui/src/lib.rs index 3af0859..621f89d 100644 --- a/crates/flowlight-gui/src/lib.rs +++ b/crates/flowlight-gui/src/lib.rs @@ -3,6 +3,9 @@ //! A library beside the binary so that `examples/markup.rs` exercises the same code the window does, rather //! than a copy of it that can drift. There is one thing in here and it is the one thing that was wrong. +pub mod chart; +pub mod tokens; + use adw::prelude::*; /// A row whose title says what it says, rather than being read as markup. @@ -31,3 +34,94 @@ pub fn literal_row_with(title: impl AsRef, subtitle: impl AsRef) -> ad row.set_subtitle(subtitle.as_ref()); row } + +/// A size, in words. +/// +/// In the library because the charts label their axes with it and the rows say it in words, and two +/// formatters would disagree about what 1,536 bytes is called on the same screen. +pub fn size(bytes: i64) -> String { + if bytes < 1_024 { + format!("{bytes} B") + } else if bytes < 1_048_576 { + format!("{:.1} kB", bytes as f64 / 1_024.0) + } else { + format!("{:.1} MB", bytes as f64 / 1_048_576.0) + } +} + +/// A tile of one figure: what it is, how much of it there is, and the colour that says which kind. +/// +/// The number is the content and is sized like it. A label above it in muted text explains it, and the whole +/// thing sits on the one card surface — the same card, everywhere, rather than three rounded rectangles that +/// are nearly the same. +pub fn stat_tile(label: &str, value: &str, token: &str) -> gtk::Box { + let tile = gtk::Box::new(gtk::Orientation::Vertical, 6); + tile.add_css_class("fl-card"); + tile.set_hexpand(true); + tile.set_margin_top(0); + for (side, amount) in [("top", 12), ("bottom", 12), ("start", 14), ("end", 14)] { + match side { + "top" => tile.set_margin_top(amount), + "bottom" => tile.set_margin_bottom(amount), + "start" => tile.set_margin_start(amount), + _ => tile.set_margin_end(amount), + } + } + + let what = gtk::Label::new(Some(label)); + what.add_css_class("fl-tile-label"); + what.set_xalign(0.0); + what.set_ellipsize(gtk::pango::EllipsizeMode::End); + + let figure = gtk::Label::new(Some(value)); + figure.add_css_class("fl-tile-value"); + figure.add_css_class(&format!("fl-{token}")); + figure.set_xalign(0.0); + figure.set_ellipsize(gtk::pango::EllipsizeMode::End); + + tile.append(&what); + tile.append(&figure); + tile +} + +/// A row of tiles, evenly wide, which is how every screen in this window starts. +pub fn tiles(of: &[gtk::Box]) -> gtk::Box { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 12); + row.set_homogeneous(true); + for tile in of { + row.append(tile); + } + row +} + +/// A small status word on a tinted capsule — never colour alone, which is why it carries a word. +pub fn pill(text: &str, token: &str) -> gtk::Label { + let pill = gtk::Label::new(Some(text)); + pill.add_css_class("fl-pill"); + pill.add_css_class(&format!("fl-{token}")); + pill.set_valign(gtk::Align::Center); + pill +} + +/// Puts the stylesheet on the display, and keeps it matching the system appearance. +/// +/// Reloaded rather than written once with both appearances in it: GTK has no media query for the colour +/// scheme, so the palette is generated for whichever one is in force and generated again when it changes. +pub fn dress(display: >k::gdk::Display) { + let provider = gtk::CssProvider::new(); + let manager = adw::StyleManager::default(); + + let apply = { + let provider = provider.clone(); + move |dark: bool| provider.load_from_data(&tokens::stylesheet(dark)) + }; + apply(manager.is_dark()); + + gtk::style_context_add_provider_for_display( + display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + manager.connect_dark_notify(move |manager| apply(manager.is_dark())); +} diff --git a/crates/flowlight-gui/src/main.rs b/crates/flowlight-gui/src/main.rs index 299a230..c8d8119 100644 --- a/crates/flowlight-gui/src/main.rs +++ b/crates/flowlight-gui/src/main.rs @@ -11,7 +11,7 @@ mod protocol; -use flowlight_gui::{literal_row, literal_row_with}; +use flowlight_gui::{literal_row, literal_row_with, size}; use adw::prelude::*; use gtk::glib; @@ -23,6 +23,12 @@ use std::rc::Rc; /// How often the window asks the daemon what has happened. const REFRESH_SECONDS: u32 = 2; +/// How many buckets the live chart asks for. +/// +/// Sixty across whatever window is chosen: enough that a minute of an hour is a column of its own, few +/// enough that each one is still wide enough to see on a laptop. +const BUCKETS: i64 = 60; + /// How many recent requests to show. const RECENT: usize = 300; @@ -150,6 +156,11 @@ impl Reading { } fn build(application: &adw::Application, socket: PathBuf) { + // The palette, before any of it is referred to. Nothing below says a colour; everything says a name. + if let Some(display) = gtk::gdk::Display::default() { + flowlight_gui::dress(&display); + } + let state = Rc::new(State { socket, window: WINDOWS.get(1).map_or(3_600, |(_, seconds)| *seconds), @@ -400,14 +411,27 @@ async fn refresh( Err(err) => say(status, &err), } } - _ => match fetch::>(socket, protocol::recent(seconds, RECENT)).await { + _ => match fetch::>(socket.clone(), protocol::recent(seconds, RECENT)).await { Ok(rows) => { + // The chart above the list is the same window as the list, bucketed. Asked for separately + // because it is a different question — "when" rather than "what" — and the daemon answers + // it from the same rows either way. + let series = fetch::(socket, protocol::series(seconds, BUCKETS)) + .await + .ok(); // Why the page is empty is part of what the page says, so it is part of what decides // whether the page is redrawn. let reading = state.reading.get(); - draw(state, 0, &(&rows, reading), &pages.live, |column| { - render_live(column, &rows, reading) - }); + let dark = adw::StyleManager::default().is_dark(); + draw( + state, + 0, + &(&rows, reading, &series, dark), + &pages.live, + |column| { + render_live(column, &rows, reading, series.as_ref(), dark); + }, + ); say(status, ""); } Err(err) => say(status, &err), @@ -534,17 +558,6 @@ fn ago(at: i64) -> String { } } -/// A size, in words. -fn size(bytes: i64) -> String { - if bytes < 1_024 { - format!("{bytes} B") - } else if bytes < 1_048_576 { - format!("{:.1} kB", bytes as f64 / 1_024.0) - } else { - format!("{:.1} MB", bytes as f64 / 1_048_576.0) - } -} - /// The agent and the process, when they are not the same thing. fn who(agent: Option<&str>, process: &str) -> String { match agent { @@ -553,7 +566,54 @@ fn who(agent: Option<&str>, process: &str) -> String { } } -fn render_live(column: >k::Box, rows: &[Request], reading: Reading) { +fn render_live( + column: >k::Box, + rows: &[Request], + reading: Reading, + series: Option<&protocol::Series>, + dark: bool, +) { + // The shape of the page: what the window is worth in four figures, then those figures over time, then + // the requests themselves. Somebody arriving wants to know whether anything is happening before they + // want to read what happened. + if let Some(series) = series + && !series.points.is_empty() + { + let received: i64 = series.points.iter().map(|point| point.received).sum(); + let sent: i64 = series.points.iter().map(|point| point.sent).sum(); + column.append(&flowlight_gui::tiles(&[ + flowlight_gui::stat_tile("Received", &size(received), "received"), + flowlight_gui::stat_tile("Sent", &size(sent), "sent"), + flowlight_gui::stat_tile("Requests", &series.requests.to_string(), "accent"), + flowlight_gui::stat_tile("Busiest bucket", &size(series.busiest), "muted"), + ])); + + let legend = gtk::Box::new(gtk::Orientation::Horizontal, 14); + legend.add_css_class("fl-chart-legend"); + let span = gtk::Label::new(Some(&format!("{} per bucket", duration(series.width)))); + span.set_hexpand(true); + span.set_xalign(0.0); + legend.append(&span); + legend.append(&flowlight_gui::pill("Received", "received")); + legend.append(&flowlight_gui::pill("Sent", "sent")); + column.append(&legend); + + let chart = flowlight_gui::chart::Chart::new(180); + chart.show( + series + .points + .iter() + .map(|point| flowlight_gui::chart::Bucket { + at: point.at, + received: point.received, + sent: point.sent, + }) + .collect(), + dark, + ); + column.append(&chart.widget); + } + if rows.is_empty() { // Four different silences. Telling somebody "nothing read" when the answer is "the session you // started eight hours ago ended" wastes their afternoon, which is how this was found. diff --git a/crates/flowlight-gui/src/protocol.rs b/crates/flowlight-gui/src/protocol.rs index 8b043d9..c56d046 100644 --- a/crates/flowlight-gui/src/protocol.rs +++ b/crates/flowlight-gui/src/protocol.rs @@ -113,6 +113,36 @@ pub struct Domain { pub servers: Vec, } +/// Traffic over time, in buckets, for drawing. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Series { + /// How many seconds each bucket covers. + pub width: i64, + /// The most bytes in any one bucket. + pub busiest: i64, + /// Bytes across the window. + pub total: i64, + /// Requests across the window. + pub requests: i64, + /// Every bucket, including the empty ones — which is what makes it a series rather than a list. + pub points: Vec, +} + +/// One bucket. +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +pub struct SeriesPoint { + /// Its start, in seconds since the epoch. + pub at: i64, + /// Requests in it. + pub requests: i64, + /// Bytes they carried. + pub bytes: i64, + /// Of those, what came back. + pub received: i64, + /// And what went out. + pub sent: i64, +} + /// What Flowlight is allowed to read, and for how long. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Budget { @@ -484,6 +514,11 @@ pub fn set_model(field: &str, value: &str) -> String { } /// Builds the request that asks a question. +/// Traffic over a window, in about this many buckets. +pub fn series(seconds: i64, buckets: i64) -> String { + format!(r#"{{"op":"series","since":{seconds},"buckets":{buckets}}}"#) +} + pub fn question(asked: &str) -> String { let asked = serde_json::to_string(asked).unwrap_or_else(|_| "\"\"".to_owned()); format!(r#"{{"op":"question","question":{asked}}}"#) diff --git a/crates/flowlight-gui/src/tokens.rs b/crates/flowlight-gui/src/tokens.rs new file mode 100644 index 0000000..b4e4a08 --- /dev/null +++ b/crates/flowlight-gui/src/tokens.rs @@ -0,0 +1,180 @@ +//! The palette, in one place, for both the stylesheet and the drawing. +//! +//! These are the same values the macOS app and the website use, with the same semantic roles: `accent` is +//! the one "light" colour and is reserved for what needs a decision; `received` and `sent` carry traffic +//! direction; `critical`, `warning` and `good` are status only and never stand in for the accent. +//! +//! Two consumers, one table. GTK's stylesheet gets `@define-color` lines generated from it, and the charts +//! get numbers, because Cairo draws with floats and knows nothing about CSS. Defining the palette twice is +//! how the chart and the text beside it end up slightly different colours. +//! +//! GTK has no media query for the colour scheme, so the stylesheet is rebuilt and reloaded when libadwaita +//! says the appearance changed, rather than written once with both appearances in it. + +/// One colour, as it appears in each appearance. `0xRRGGBB`. +pub struct Token { + /// The name it is known by, in CSS as `@fl_` and here as itself. + pub name: &'static str, + /// Light appearance. + pub light: u32, + /// Dark appearance. + pub dark: u32, +} + +/// The palette. Surfaces deepest to lightest, then text, then the semantic roles. +pub const PALETTE: &[Token] = &[ + Token { + name: "ground", + light: 0xf4f5f8, + dark: 0x0d0f16, + }, + Token { + name: "surface", + light: 0xffffff, + dark: 0x151824, + }, + Token { + name: "surface_deep", + light: 0xf0f1f6, + dark: 0x10131d, + }, + Token { + name: "surface_2", + light: 0xeceef4, + dark: 0x1c2030, + }, + Token { + name: "line", + light: 0xdde0e8, + dark: 0x262b3b, + }, + Token { + name: "ink", + light: 0x11131a, + dark: 0xeef0f6, + }, + Token { + name: "muted", + light: 0x565d70, + dark: 0xa3a9bb, + }, + Token { + name: "faint", + light: 0x8a90a2, + dark: 0x737a8f, + }, + Token { + name: "accent", + light: 0x4a3aa7, + dark: 0x9085e9, + }, + Token { + name: "received", + light: 0x2a78d6, + dark: 0x4a93ea, + }, + Token { + name: "sent", + light: 0xd95926, + dark: 0xe5723f, + }, + Token { + name: "critical", + light: 0xc93b33, + dark: 0xec6a62, + }, + Token { + name: "warning", + light: 0xb86e00, + dark: 0xe0a13a, + }, + Token { + name: "good", + light: 0x1f9d6b, + dark: 0x4fb98a, + }, + Token { + name: "tool", + light: 0x6f4bc7, + dark: 0xb4a2f2, + }, +]; + +/// The rules, which do not change with the appearance. +const RULES: &str = include_str!("flowlight.css"); + +/// One colour by name, as Cairo wants it: three floats from zero to one. +/// +/// An unknown name is mid-grey rather than a panic. A chart drawn in the wrong grey is a mistake somebody +/// can see and fix; a window that will not open is one they cannot. +pub fn colour(name: &str, dark: bool) -> (f64, f64, f64) { + let Some(token) = PALETTE.iter().find(|token| token.name == name) else { + return (0.5, 0.5, 0.5); + }; + let value = if dark { token.dark } else { token.light }; + ( + f64::from((value >> 16) & 0xff) / 255.0, + f64::from((value >> 8) & 0xff) / 255.0, + f64::from(value & 0xff) / 255.0, + ) +} + +/// The whole stylesheet for one appearance: the palette as `@define-color`, then the rules. +pub fn stylesheet(dark: bool) -> String { + let mut css = String::with_capacity(RULES.len() + PALETTE.len() * 40); + for token in PALETTE { + let value = if dark { token.dark } else { token.light }; + css.push_str(&format!("@define-color fl_{} #{value:06x};\n", token.name)); + } + css.push_str(RULES); + css +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_token_is_defined_for_both_appearances() { + // A token that reads the same in both is almost always a token somebody forgot to finish. The two + // greys are deliberate; nothing else may match. + let same: Vec<&str> = PALETTE + .iter() + .filter(|token| token.light == token.dark) + .map(|token| token.name) + .collect(); + assert!( + same.is_empty(), + "these read identically in both appearances: {same:?}" + ); + } + + #[test] + fn the_stylesheet_carries_the_palette_it_was_asked_for() { + let light = stylesheet(false); + let dark = stylesheet(true); + assert!( + light.contains("@define-color fl_accent #4a3aa7;"), + "{light}" + ); + assert!(dark.contains("@define-color fl_accent #9085e9;"), "{dark}"); + // And the rules come along with it, or the names resolve to nothing. + assert!(light.contains(".fl-card")); + assert!(dark.contains(".fl-card")); + } + + #[test] + fn a_colour_comes_back_as_cairo_wants_it() { + let (r, g, b) = colour("sent", false); + assert!((r - 0xd9 as f64 / 255.0).abs() < 1e-9); + assert!((g - 0x59 as f64 / 255.0).abs() < 1e-9); + assert!((b - 0x26 as f64 / 255.0).abs() < 1e-9); + // Dark is a different colour, not the same one. + assert_ne!(colour("sent", true), colour("sent", false)); + } + + #[test] + fn a_name_nobody_defined_is_grey_rather_than_a_crash() { + assert_eq!(colour("chartreuse", false), (0.5, 0.5, 0.5)); + } +} diff --git a/crates/flowlight-store/src/lib.rs b/crates/flowlight-store/src/lib.rs index dbca383..c835abd 100644 --- a/crates/flowlight-store/src/lib.rs +++ b/crates/flowlight-store/src/lib.rs @@ -467,6 +467,13 @@ pub struct SeriesRow { pub requests: i64, /// Bytes they carried. pub bytes: i64, + /// Of those bytes, the ones that came back. + pub received: i64, + /// And the ones that went out. + /// + /// Split because a chart that draws one line for both answers neither "is something uploading" nor "is + /// something downloading", which are the two questions somebody watching traffic actually has. + pub sent: i64, } /// One day's traffic between one process and one host. @@ -1488,7 +1495,10 @@ impl Store { self.flush()?; let bucket = bucket.max(1); let mut statement = self.connection.prepare( - "SELECT (at / ?4) * ?4 AS bucket, count(*), coalesce(sum(bytes), 0) FROM requests + "SELECT (at / ?4) * ?4 AS bucket, count(*), coalesce(sum(bytes), 0), + coalesce(sum(CASE WHEN direction = 'in' THEN bytes ELSE 0 END), 0), + coalesce(sum(CASE WHEN direction = 'out' THEN bytes ELSE 0 END), 0) + FROM requests WHERE at >= ?1 AND at < ?2 AND (?3 IS NULL OR process = ?3) GROUP BY bucket ORDER BY bucket", )?; @@ -1497,6 +1507,8 @@ impl Store { at: row.get(0)?, requests: row.get(1)?, bytes: row.get(2)?, + received: row.get(3)?, + sent: row.get(4)?, }) })?; Ok(rows.collect::>>()?) @@ -3861,6 +3873,25 @@ mod tests { assert_eq!(again[0].at, 3_600); } + #[test] + fn a_bucket_says_which_way_its_bytes_went() { + // A chart draws two directions. One summed column cannot say which way anything went, and a window + // that answers "300 bytes" to "is something uploading" has answered a different question. + let mut store = Store::in_memory().unwrap(); + store + .record_request(request(3_600, "curl", "example.com", 100)) + .unwrap(); + let mut back = request(3_650, "curl", "example.com", 900); + back.direction = "in".to_owned(); + store.record_request(back).unwrap(); + + let series = store.series(None, 0, 10_000, 3_600).unwrap(); + assert_eq!(series.len(), 1); + assert_eq!(series[0].bytes, 1_000); + assert_eq!(series[0].sent, 100); + assert_eq!(series[0].received, 900); + } + // Export #[test]