From 80d55a35b558eddc5263545d8835adfd8fca6cae Mon Sep 17 00:00:00 2001 From: blessdyb Date: Thu, 1 Oct 2026 10:29:29 -0700 Subject: [PATCH 1/2] Traffic over time, over the socket The store has bucketed traffic and has had it all along; nothing could ask for it. A chart needs evenly spaced buckets across the whole window including the empty ones, so the filling-in happens here, where the window and the bucket width are known, rather than in whoever draws it. The buckets now say which way their bytes went. One summed column cannot answer "is something uploading", which is half of what somebody watching traffic wants to know. Co-Authored-By: Claude Opus 5 (1M context) --- crates/flowlight-daemon/src/control.rs | 20 ++++++++ crates/flowlight-daemon/src/views.rs | 71 ++++++++++++++++++++++++++ crates/flowlight-store/src/lib.rs | 33 +++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/crates/flowlight-daemon/src/control.rs b/crates/flowlight-daemon/src/control.rs index f6bd56b..066efcb 100644 --- a/crates/flowlight-daemon/src/control.rs +++ b/crates/flowlight-daemon/src/control.rs @@ -76,6 +76,15 @@ pub enum Request { #[serde(default = "a_day")] since: i64, }, + /// Traffic over time, in buckets, for drawing. + Series { + /// How far back, in seconds. + #[serde(default = "an_hour")] + since: i64, + /// About how many buckets to divide it into. + #[serde(default = "sixty")] + buckets: i64, + }, /// Every rule. Rules, /// What Flowlight is allowed to read, and for how long. @@ -268,6 +277,11 @@ pub enum Request { }, } +/// The default number of buckets: one a minute across an hour, which is what the window asks for. +fn sixty() -> 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-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] From e88834f5a093a00a9fe522f639f31f7617183739 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Thu, 1 Oct 2026 10:35:42 -0700 Subject: [PATCH 2/2] A design system, and charts drawn in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window was eight tabs of text rows. The macOS app opens on four figures and a chart of them, in a palette with names; this is the first half of making the two the same program. The palette is `tokens.rs`: the same values and the same semantic roles as the macOS app and the website, in one table with two consumers. The stylesheet gets `@define-color` lines generated from it; the charts get floats, because Cairo draws with numbers and knows nothing about CSS. Defining it twice is how a chart and the text beside it end up slightly different colours. GTK has no media query for the colour scheme, so the sheet is regenerated and reloaded when libadwaita says the appearance changed. Charts are a `GtkDrawingArea` and Cairo rather than a dependency. The one chart this window needs is a two-direction area chart and its arithmetic is forty lines — against a new library on a machine that is already asking a 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, so the directions read apart rather than being summed into a line that answers neither question. The arithmetic is separate from the drawing and tested, including the part that was wrong first time: `ceiling` rounded to 100000, which is round in bytes and reads "97.7 kB" on an axis. It now rounds in the unit the label is written in, because that is where somebody reads it. And the check draws. Cairo renders onto a buffer with no display, no window and no screenshot, and the ink is counted — a chart that draws nothing looks exactly like a quiet hour in a running window, which is the one way this could have shipped broken unnoticed. Run against a `render` that returns early first: 0 of 180000 pixels, exit 1. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 + Cargo.lock | 28 +-- Cargo.toml | 2 +- README.md | 13 ++ crates/flowlight-gui/examples/chart.rs | 79 ++++++++ crates/flowlight-gui/src/chart.rs | 266 +++++++++++++++++++++++++ crates/flowlight-gui/src/flowlight.css | 66 ++++++ crates/flowlight-gui/src/lib.rs | 94 +++++++++ crates/flowlight-gui/src/main.rs | 94 +++++++-- crates/flowlight-gui/src/protocol.rs | 35 ++++ crates/flowlight-gui/src/tokens.rs | 180 +++++++++++++++++ 11 files changed, 834 insertions(+), 32 deletions(-) create mode 100644 crates/flowlight-gui/examples/chart.rs create mode 100644 crates/flowlight-gui/src/chart.rs create mode 100644 crates/flowlight-gui/src/flowlight.css create mode 100644 crates/flowlight-gui/src/tokens.rs 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 ` = (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)); + } +}