commit 17395b1b09857eed4eff2b790babd491776b3f14
parent 739074959ed6b8be1c67e8a811159379ef9b1c92
Author: William Casarin <jb55@jb55.com>
Date: Tue, 20 Jan 2026 19:15:32 -0800
dashboard: client charts
Fixes: https://github.com/damus-io/notedeck/issues/1256
Signed-off-by: William Casarin <jb55@jb55.com>
Diffstat:
4 files changed, 347 insertions(+), 13 deletions(-)
diff --git a/crates/notedeck_dashboard/src/chart.rs b/crates/notedeck_dashboard/src/chart.rs
@@ -184,3 +184,71 @@ pub fn horizontal_bar_chart(
outer_resp
}
+
+pub fn stacked_bars(
+ ui: &mut Ui,
+ size: Vec2,
+ buckets: &[Vec<(Color32, f32)>], // each bucket: vec of (color, value)
+) -> Response {
+ let (rect, resp) = ui.allocate_exact_size(size, Sense::hover());
+ let painter = ui.painter_at(rect);
+
+ painter.rect_filled(rect, 6.0, ui.visuals().faint_bg_color);
+
+ if buckets.is_empty() {
+ return resp;
+ }
+
+ // find max total per bucket for scaling
+ let mut max_total = 0.0_f32;
+ for b in buckets {
+ let t: f32 = b.iter().map(|(_, v)| v.max(0.0)).sum();
+ max_total = max_total.max(t);
+ }
+ if max_total <= 0.0 {
+ return resp;
+ }
+
+ let n = buckets.len();
+ let bw = rect.width() / n as f32;
+
+ for (i, b) in buckets.iter().enumerate() {
+ let x0 = rect.left() + i as f32 * bw;
+ let x1 = x0 + bw;
+
+ let total: f32 = b.iter().map(|(_, v)| v.max(0.0)).sum();
+ let h_total = rect.height() * (total / max_total);
+
+ // Optional: center bars if you want; simplest is fill from bottom with total scaling:
+ let mut y0 = rect.bottom();
+ let y_min = rect.bottom() - h_total;
+
+ for (color, v) in b {
+ let v = v.max(0.0);
+ if v <= 0.0 {
+ continue;
+ }
+ let seg_h = h_total * (v / total.max(1.0));
+ let seg_rect = Rect::from_min_max(
+ Pos2::new(x0 + 1.0, (y0 - seg_h).max(y_min)),
+ Pos2::new(x1 - 1.0, y0),
+ );
+ painter.rect_filled(seg_rect, 0.0, *color);
+ y0 -= seg_h;
+ }
+
+ // faint outline
+ let outline = Rect::from_min_max(
+ Pos2::new(x0 + 1.0, y_min),
+ Pos2::new(x1 - 1.0, rect.bottom()),
+ );
+ painter.rect_stroke(
+ outline,
+ 0.0,
+ Stroke::new(1.0, ui.visuals().widgets.inactive.bg_stroke.color),
+ StrokeKind::Middle,
+ );
+ }
+
+ resp
+}
diff --git a/crates/notedeck_dashboard/src/lib.rs b/crates/notedeck_dashboard/src/lib.rs
@@ -1,3 +1,4 @@
+use nostrdb::Note;
use rustc_hash::FxHashMap;
use std::thread;
use std::time::{Duration, Instant};
@@ -10,6 +11,7 @@ use notedeck::{AppContext, AppResponse, try_process_events_core};
use chrono::{Datelike, TimeZone, Utc};
mod chart;
+mod sparkline;
mod ui;
// ----------------------
@@ -46,14 +48,36 @@ impl Period {
#[derive(Default, Clone, Debug)]
struct Bucket {
pub total: u64,
- pub kinds: rustc_hash::FxHashMap<u64, u64>,
+ pub kinds: rustc_hash::FxHashMap<u64, u32>,
+ pub clients: rustc_hash::FxHashMap<String, u32>,
+}
+
+fn note_client_tag<'a>(note: &Note<'a>) -> Option<&'a str> {
+ for tag in note.tags() {
+ if tag.count() < 2 {
+ continue;
+ }
+
+ let Some("client") = tag.get_str(0) else {
+ continue;
+ };
+
+ return tag.get_str(1);
+ }
+
+ None
}
impl Bucket {
#[inline(always)]
- pub fn bump(&mut self, kind: u64) {
+ pub fn bump(&mut self, note: &Note<'_>) {
self.total += 1;
- *self.kinds.entry(kind).or_default() += 1;
+ *self.kinds.entry(note.kind() as u64).or_default() += 1;
+ if let Some(client) = note_client_tag(note) {
+ *self.clients.entry(client.to_string()).or_default() += 1;
+ } else {
+ // TODO(jb55): client fingerprinting ?
+ }
}
}
@@ -104,7 +128,9 @@ impl RollingCache {
}
#[inline(always)]
- pub fn bump(&mut self, ts: i64, kind: u64) {
+ pub fn bump(&mut self, note: &Note<'_>) {
+ let ts = note.created_at() as i64;
+
// bucket windows are [end-(i+1)*size, end-i*size)
// so treat `end` itself as "future"
let delta = (self.anchor_end_ts - 1) - ts;
@@ -118,7 +144,7 @@ impl RollingCache {
return; // outside window
}
- self.buckets[idx].bump(kind);
+ self.buckets[idx].bump(note);
}
}
@@ -431,13 +457,10 @@ fn materialize_single_pass(
let emit_every = Duration::from_millis(32);
let _ = ndb.fold(&txn, &filters, &mut acc, |acc, note| {
- let ts = note.created_at() as i64;
- let kind = note.kind() as u64;
-
- acc.state.total.bump(kind);
- acc.state.daily.bump(ts, kind);
- acc.state.weekly.bump(ts, kind);
- acc.state.monthly.bump(ts, kind);
+ acc.state.total.bump(¬e);
+ acc.state.daily.bump(¬e);
+ acc.state.weekly.bump(¬e);
+ acc.state.monthly.bump(¬e);
let now = Instant::now();
if now.saturating_duration_since(acc.last_emit) >= emit_every {
@@ -505,7 +528,7 @@ fn top_kinds_over(cache: &RollingCache, limit: usize) -> Vec<(u64, u64)> {
for b in &cache.buckets {
for (kind, count) in &b.kinds {
- *agg.entry(*kind).or_default() += count;
+ *agg.entry(*kind).or_default() += *count as u64;
}
}
diff --git a/crates/notedeck_dashboard/src/sparkline.rs b/crates/notedeck_dashboard/src/sparkline.rs
@@ -0,0 +1,89 @@
+use egui::{Color32, Pos2, Response, Sense, Stroke, Ui, Vec2};
+
+#[derive(Clone, Copy)]
+pub struct SparkStyle {
+ pub stroke: Stroke,
+ pub fill_alpha: u8,
+ pub rounding: f32,
+}
+
+impl Default for SparkStyle {
+ fn default() -> Self {
+ Self {
+ stroke: Stroke::new(1.5, Color32::WHITE),
+ fill_alpha: 40,
+ rounding: 3.0,
+ }
+ }
+}
+
+/// values are samples over time (left=oldest, right=newest)
+pub fn sparkline(
+ ui: &mut Ui,
+ size: Vec2,
+ values: &[f32],
+ color: Color32,
+ style: SparkStyle,
+) -> Response {
+ let (rect, resp) = ui.allocate_exact_size(size, Sense::hover());
+ let painter = ui.painter_at(rect);
+
+ // background
+ //painter.rect_filled(rect, style.rounding, ui.visuals().widgets.inactive.bg_fill);
+
+ if values.len() < 2 {
+ return resp;
+ }
+
+ let mut min_v = f32::INFINITY;
+ let mut max_v = f32::NEG_INFINITY;
+ for &v in values {
+ let v = v.max(0.0);
+ min_v = min_v.min(v);
+ max_v = max_v.max(v);
+ }
+ // avoid div by zero, also allow flat lines
+ let span = (max_v - min_v).max(1.0);
+
+ let n = values.len();
+ let dx = rect.width() / (n.saturating_sub(1) as f32).max(1.0);
+
+ let mut pts: Vec<Pos2> = Vec::with_capacity(n);
+ for (i, &v) in values.iter().enumerate() {
+ let t = (v.max(0.0) - min_v) / span; // 0..1
+ let x = rect.left() + (i as f32) * dx;
+ let y = rect.bottom() - t * rect.height();
+ pts.push(Pos2::new(x, y));
+ }
+
+ // fill under curve
+ /*
+ let mut fill_pts = Vec::with_capacity(pts.len() + 2);
+ fill_pts.extend_from_slice(&pts);
+ fill_pts.push(Pos2::new(rect.right(), rect.bottom()));
+ fill_pts.push(Pos2::new(rect.left(), rect.bottom()));
+
+ let mut fill_color = color;
+ fill_color = Color32::from_rgba_premultiplied(
+ fill_color.r(),
+ fill_color.g(),
+ fill_color.b(),
+ style.fill_alpha,
+ );
+
+ painter.add(egui::Shape::Path(egui::epaint::PathShape {
+ points: fill_pts,
+ closed: true,
+ fill: fill_color,
+ stroke: Stroke::NONE.into(),
+ }));
+ */
+
+ // line
+ painter.add(egui::Shape::line(
+ pts,
+ Stroke::new(style.stroke.width, color),
+ ));
+
+ resp
+}
diff --git a/crates/notedeck_dashboard/src/ui.rs b/crates/notedeck_dashboard/src/ui.rs
@@ -5,6 +5,7 @@ use std::time::Duration;
use std::time::Instant;
use crate::Dashboard;
+use crate::FxHashMap;
use crate::Period;
use crate::RollingCache;
use crate::chart::Bar;
@@ -290,6 +291,159 @@ fn dashboard_ui_inner(dashboard: &mut Dashboard, ui: &mut egui::Ui) {
ui.add_sized(size, |ui: &mut egui::Ui| {
card_ui(ui, min_card, |ui| kinds_ui(dashboard, ui))
});
+ ui.add_sized(size, |ui: &mut egui::Ui| {
+ card_ui(ui, min_card, |ui| clients_stack_ui(dashboard, ui))
+ });
+ ui.add_sized(size, |ui: &mut egui::Ui| {
+ card_ui(ui, min_card, |ui| clients_trends_ui(dashboard, ui))
+ });
},
);
}
+
+fn client_series(cache: &RollingCache, client: &str) -> Vec<f32> {
+ // left=oldest, right=newest like your series_bars_for_kind does
+ let n = cache.buckets.len();
+ let mut out = Vec::with_capacity(n);
+ for i in (0..n).rev() {
+ let v = *cache.buckets[i].clients.get(client).unwrap_or(&0) as f32;
+ out.push(v);
+ }
+ out
+}
+
+pub fn clients_trends_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) {
+ card_header_ui(ui, "Clients (trend)");
+ ui.add_space(8.0);
+
+ let limit = 10;
+
+ let cache = match dashboard.period {
+ Period::Daily => &dashboard.state.daily,
+ Period::Weekly => &dashboard.state.weekly,
+ Period::Monthly => &dashboard.state.monthly,
+ };
+
+ let top = top_clients_over(cache, limit); // your existing “top N” is fine as a selector
+ if top.is_empty() && dashboard.last_error.is_none() {
+ ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak());
+ return;
+ }
+ if top.is_empty() {
+ ui.label("No client tags");
+ return;
+ }
+
+ let spark_w = (ui.available_width() - 140.0).max(80.0);
+ let spark_h = 18.0;
+
+ for (row_i, (client, total)) in top.iter().enumerate() {
+ ui.horizontal(|ui| {
+ ui.label(RichText::new(client).small());
+ ui.add_space(6.0);
+
+ let series = client_series(cache, client);
+
+ let resp = crate::sparkline::sparkline(
+ ui,
+ egui::vec2(spark_w, spark_h),
+ &series,
+ palette(row_i),
+ crate::sparkline::SparkStyle::default(),
+ );
+
+ // tooltip: last bucket + total
+ if resp.hovered() {
+ let last = series.last().copied().unwrap_or(0.0);
+ resp.on_hover_text(format!("total: {total}\nlatest bucket: {:.0}", last));
+ }
+
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.label(RichText::new(total.to_string()).small().strong());
+ });
+ });
+ ui.add_space(4.0);
+ }
+
+ footer_status_ui(
+ ui,
+ dashboard.running,
+ dashboard.last_error.as_deref(),
+ dashboard.last_snapshot,
+ dashboard.last_duration,
+ );
+}
+
+fn stacked_clients_over_time(
+ cache: &RollingCache,
+ top: &[(String, u64)],
+) -> Vec<Vec<(egui::Color32, f32)>> {
+ let n = cache.buckets.len();
+ let mut out = Vec::with_capacity(n);
+
+ // oldest -> newest
+ for i in (0..n).rev() {
+ let mut segs = Vec::with_capacity(top.len());
+ for (idx, (name, _)) in top.iter().enumerate() {
+ let v = *cache.buckets[i].clients.get(name).unwrap_or(&0) as f32;
+ segs.push((palette(idx), v));
+ }
+ out.push(segs);
+ }
+ out
+}
+
+pub fn clients_stack_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) {
+ card_header_ui(ui, "Clients (stacked over time)");
+ ui.add_space(8.0);
+
+ let limit = 6; // stacked charts get noisy fast; 5–7 is usually sweet spot
+
+ let cache = dashboard.selected_cache();
+ let top = top_clients_over(cache, limit);
+
+ if top.is_empty() && dashboard.last_error.is_none() {
+ ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak());
+ } else if top.is_empty() {
+ ui.label("No client tags");
+ } else {
+ let buckets = stacked_clients_over_time(cache, &top);
+ let w = ui.available_width().max(120.0);
+ let h = 70.0;
+
+ let resp = crate::chart::stacked_bars(ui, egui::vec2(w, h), &buckets);
+
+ // legend
+ ui.add_space(6.0);
+ ui.horizontal_wrapped(|ui| {
+ for (i, (name, _)) in top.iter().enumerate() {
+ ui.label(RichText::new("■").color(palette(i)));
+ ui.label(RichText::new(name).small());
+ ui.add_space(10.0);
+ }
+ });
+
+ // you can also attach hover-to-bucket tooltip later if you want (based on pointer x -> bucket index)
+ let _ = resp;
+ }
+}
+
+fn top_clients_over(cache: &RollingCache, limit: usize) -> Vec<(String, u64)> {
+ let mut agg: FxHashMap<String, u64> = FxHashMap::default();
+
+ for b in &cache.buckets {
+ for (client, count) in &b.clients {
+ *agg.entry(client.clone()).or_default() += *count as u64;
+ }
+ }
+
+ let mut out: Vec<(String, u64)> = agg.into_iter().collect();
+
+ // sort desc by count; tie-break by name for stability
+ out.sort_by(|(a_name, a_cnt), (b_name, b_cnt)| {
+ b_cnt.cmp(a_cnt).then_with(|| a_name.cmp(b_name))
+ });
+
+ out.truncate(limit);
+ out
+}