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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 14 additions & 14 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg` near the start.
Expand Down
20 changes: 20 additions & 0 deletions crates/flowlight-daemon/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 } => {
Expand Down
71 changes: 71 additions & 0 deletions crates/flowlight-daemon/src/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SeriesView> {
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<i64, &flowlight_store::SeriesRow> =
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<SeriesPoint>,
}

/// 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<CoverageView> {
let coverage = store.coverage(since)?;
Expand Down
79 changes: 79 additions & 0 deletions crates/flowlight-gui/examples/chart.rs
Original file line number Diff line number Diff line change
@@ -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<Bucket> = (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");
}
Loading
Loading