notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck_dashboard/src/chart.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dashboard/src/lib.rs | 49++++++++++++++++++++++++++++++++++++-------------
Acrates/notedeck_dashboard/src/sparkline.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dashboard/src/ui.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(&note); + acc.state.daily.bump(&note); + acc.state.weekly.bump(&note); + acc.state.monthly.bump(&note); 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 +}