commit 914866e74aac0551c17724826d7a4a5374c1451f
parent 4c2173c23ac85177df1c34824f213ed5ab59b5c4
Author: William Casarin <jb55@jb55.com>
Date: Wed, 14 Jan 2026 12:29:09 -0800
app: initial dashboard app
Signed-off-by: William Casarin <jb55@jb55.com>
Diffstat:
12 files changed, 852 insertions(+), 6 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3845,7 +3845,7 @@ dependencies = [
[[package]]
name = "nostrdb"
version = "0.8.0"
-source = "git+https://github.com/damus-io/nostrdb-rs?rev=8148dd3cea9bc8ff0bc510c720d0c51f327a0a1a#8148dd3cea9bc8ff0bc510c720d0c51f327a0a1a"
+source = "git+https://github.com/damus-io/nostrdb-rs?rev=04702963cd9fc985976005743f06310c619428b1#04702963cd9fc985976005743f06310c619428b1"
dependencies = [
"bindgen 0.69.5",
"cc",
@@ -3938,6 +3938,7 @@ dependencies = [
"notedeck",
"notedeck_clndash",
"notedeck_columns",
+ "notedeck_dashboard",
"notedeck_dave",
"notedeck_messages",
"notedeck_notebook",
@@ -4034,6 +4035,19 @@ dependencies = [
]
[[package]]
+name = "notedeck_dashboard"
+version = "0.7.1"
+dependencies = [
+ "crossbeam-channel",
+ "egui",
+ "nostrdb",
+ "notedeck",
+ "notedeck_ui",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
name = "notedeck_dave"
version = "0.7.1"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
@@ -10,13 +10,16 @@ members = [
"crates/notedeck_notebook",
"crates/notedeck_ui",
"crates/notedeck_clndash",
-
- "crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui", "crates/notedeck_clndash",
+ "crates/notedeck_dashboard",
+ "crates/tokenator",
+ "crates/enostr",
]
[workspace.dependencies]
opener = "0.8.2"
chrono = "0.4.40"
+crossbeam-channel = "0.5"
+crossbeam = "0.8.4"
base32 = "0.4.0"
base64 = "0.22.1"
rmpv = "1.3.0"
@@ -52,11 +55,12 @@ md5 = "0.7.0"
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip44", "nip49"] }
nwc = "0.39.0"
mio = { version = "1.0.3", features = ["os-poll", "net"] }
-nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "8148dd3cea9bc8ff0bc510c720d0c51f327a0a1a" }
+nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "04702963cd9fc985976005743f06310c619428b1" }
#nostrdb = "0.6.1"
notedeck = { path = "crates/notedeck" }
notedeck_chrome = { path = "crates/notedeck_chrome" }
notedeck_clndash = { path = "crates/notedeck_clndash" }
+notedeck_dashboard = { path = "crates/notedeck_dashboard" }
notedeck_columns = { path = "crates/notedeck_columns" }
notedeck_dave = { path = "crates/notedeck_dave" }
notedeck_messages = { path = "crates/notedeck_messages" }
diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml
@@ -52,8 +52,8 @@ regex = "1"
chrono = { workspace = true }
indexmap = {workspace = true}
rand = {workspace = true}
-crossbeam-channel = "0.5"
-crossbeam = "0.8.4"
+crossbeam-channel = { workspace = true }
+crossbeam = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
http-body-util = { workspace = true }
diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml
@@ -21,6 +21,7 @@ notedeck_dave = { workspace = true }
notedeck_messages = { workspace = true, optional = true }
notedeck_notebook = { workspace = true, optional = true }
notedeck_clndash = { workspace = true, optional = true }
+notedeck_dashboard = { workspace = true, optional = true }
notedeck = { workspace = true }
nostrdb = { workspace = true }
puffin = { workspace = true, optional = true }
@@ -57,6 +58,7 @@ tracy = ["profiling/profile-with-tracy"]
messages = ["notedeck_messages"]
notebook = ["notedeck_notebook"]
clndash = ["notedeck_clndash"]
+dashboard = ["notedeck_dashboard"]
[target.'cfg(target_os = "android")'.dependencies]
tracing-logcat = "0.1.0"
diff --git a/crates/notedeck_chrome/src/app.rs b/crates/notedeck_chrome/src/app.rs
@@ -9,6 +9,9 @@ use notedeck_clndash::ClnDash;
#[cfg(feature = "messages")]
use notedeck_messages::MessagesApp;
+#[cfg(feature = "dashboard")]
+use notedeck_dashboard::Dashboard;
+
#[cfg(feature = "notebook")]
use notedeck_notebook::Notebook;
@@ -16,12 +19,19 @@ use notedeck_notebook::Notebook;
pub enum NotedeckApp {
Dave(Box<Dave>),
Columns(Box<Damus>),
+
#[cfg(feature = "notebook")]
Notebook(Box<Notebook>),
+
#[cfg(feature = "clndash")]
ClnDash(Box<ClnDash>),
+
#[cfg(feature = "messages")]
Messages(Box<MessagesApp>),
+
+ #[cfg(feature = "dashboard")]
+ Dashboard(Box<Dashboard>),
+
Other(Box<dyn notedeck::App>),
}
@@ -41,6 +51,9 @@ impl notedeck::App for NotedeckApp {
#[cfg(feature = "messages")]
NotedeckApp::Messages(dms) => dms.update(ctx, ui),
+ #[cfg(feature = "dashboard")]
+ NotedeckApp::Dashboard(db) => db.update(ctx, ui),
+
NotedeckApp::Other(other) => other.update(ctx, ui),
}
}
diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs
@@ -30,6 +30,9 @@ use notedeck_dave::{Dave, DaveAvatar};
#[cfg(feature = "messages")]
use notedeck_messages::MessagesApp;
+#[cfg(feature = "dashboard")]
+use notedeck_dashboard::Dashboard;
+
#[cfg(feature = "clndash")]
use notedeck_ui::expanding_button;
@@ -164,6 +167,9 @@ impl Chrome {
#[cfg(feature = "messages")]
chrome.add_app(NotedeckApp::Messages(Box::new(MessagesApp::new())));
+ #[cfg(feature = "dashboard")]
+ chrome.add_app(NotedeckApp::Dashboard(Box::new(Dashboard::default())));
+
#[cfg(feature = "notebook")]
chrome.add_app(NotedeckApp::Notebook(Box::default()));
@@ -787,6 +793,11 @@ fn topdown_sidebar(
tr!(loc, "Messaging", "Button to go to the messaging app")
}
+ #[cfg(feature = "dashboard")]
+ NotedeckApp::Dashboard(_) => {
+ tr!(loc, "Dashboard", "Button to go to the dashboard app")
+ }
+
#[cfg(feature = "notebook")]
NotedeckApp::Notebook(_) => {
tr!(loc, "Notebook", "Button to go to the Notebook app")
@@ -821,6 +832,11 @@ fn topdown_sidebar(
);
}
+ #[cfg(feature = "dashboard")]
+ NotedeckApp::Dashboard(_columns_app) => {
+ ui.add(app_images::algo_image());
+ }
+
#[cfg(feature = "messages")]
NotedeckApp::Messages(_dms) => {
ui.add(app_images::new_message_image());
diff --git a/crates/notedeck_dashboard/Cargo.toml b/crates/notedeck_dashboard/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "notedeck_dashboard"
+edition = "2024"
+version.workspace = true
+
+[dependencies]
+egui = { workspace = true }
+nostrdb = { workspace = true }
+crossbeam-channel = { workspace = true }
+notedeck = { workspace = true }
+notedeck_ui = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
diff --git a/crates/notedeck_dashboard/README.md b/crates/notedeck_dashboard/README.md
@@ -0,0 +1,102 @@
+# Notedeck Dashboard
+
+A minimal, real-time dashboard for **Notedeck**, built with **egui** and backed by **nostrdb**.
+
+This app renders live statistics from a Nostr database without blocking the UI, using a single long-lived worker thread and progressive snapshots. No loading screens, no spinners—data fades in as it’s computed.
+
+## What it does (today)
+
+* Counts **total notes** in the database
+* Shows **top N event kinds** as a horizontal bar chart
+* Refreshes automatically on a fixed interval
+* Streams intermediate results to the UI while scanning
+* Keeps previous values visible during refreshes
+
+## Architecture overview
+
+### UI thread
+
+* Pure egui rendering
+* Never blocks on database work
+* Reacts to snapshots pushed from the worker
+* Requests repaints only when new data arrives
+
+### Worker thread
+
+* Single persistent thread
+* Runs one scan per refresh
+* Emits periodic snapshots (~30ms cadence)
+* Uses a single `nostrdb::Transaction` per run
+* Communicates via `crossbeam_channel`
+
+### Data flow
+
+```
+UI ── Refresh cmd ──▶ Worker
+UI ◀─ Snapshot msgs ◀─ Worker
+UI ◀─ Finished msg ◀─ Worker
+```
+
+## Code layout
+
+```
+src/
+├── lib.rs # App entry, worker orchestration, refresh logic
+├── ui.rs # egui cards, charts, and status UI
+└── chart.rs # Reusable horizontal bar chart widget
+```
+
+### `chart.rs`
+
+* Custom horizontal bar chart
+* Value labels, hover tooltips, and color palette
+* Designed to be generic and reusable
+
+### `lib.rs`
+
+* Implements `notedeck::App`
+* Owns worker lifecycle and refresh policy
+* Handles snapshot merging and UI state
+
+### `ui.rs`
+
+* Card-based layout
+* Totals view + kinds bar chart
+* Footer status showing freshness and timing
+
+## Design goals
+
+* **Zero UI stalls**
+ Database scans never block rendering.
+
+* **Progressive feedback**
+ Partial results are better than spinners.
+
+* **Simple concurrency**
+ One worker, one job at a time, explicit messaging.
+
+* **Low ceremony**
+ No async runtime, no task pools, no state machines.
+
+## Non-goals (for now)
+
+* Multiple workers
+* Historical comparisons
+* Persistence of dashboard state
+* Configuration UI
+* Fancy animations
+
+## Requirements
+
+* Rust (stable)
+* `egui`
+* `notedeck`
+* `nostrdb`
+* `crossbeam-channel`
+
+This crate is intended to be built and run as part of a Notedeck environment.
+
+## Status
+
+Early but functional.
+The core threading, snapshotting, and rendering model is in place and intentionally conservative. Future changes should preserve the “always responsive” property above all else.
diff --git a/crates/notedeck_dashboard/src/chart.rs b/crates/notedeck_dashboard/src/chart.rs
@@ -0,0 +1,186 @@
+use egui::{Align2, Color32, FontId, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2};
+
+pub fn palette(i: usize) -> Color32 {
+ const P: [Color32; 10] = [
+ Color32::from_rgb(231, 76, 60),
+ Color32::from_rgb(52, 152, 219),
+ Color32::from_rgb(46, 204, 113),
+ Color32::from_rgb(155, 89, 182),
+ Color32::from_rgb(241, 196, 15),
+ Color32::from_rgb(230, 126, 34),
+ Color32::from_rgb(26, 188, 156),
+ Color32::from_rgb(149, 165, 166),
+ Color32::from_rgb(52, 73, 94),
+ Color32::from_rgb(233, 150, 122),
+ ];
+ P[i % P.len()]
+}
+
+// ----------------------
+// Bar chart (unchanged)
+// ----------------------
+
+#[derive(Debug, Clone)]
+pub struct Bar {
+ pub label: String,
+ pub value: f32,
+ pub color: Color32,
+}
+
+#[derive(Clone, Copy)]
+pub struct BarChartStyle {
+ pub row_height: f32,
+ pub gap: f32,
+ pub rounding: f32,
+ pub show_values: bool,
+ pub value_precision: usize,
+}
+
+impl Default for BarChartStyle {
+ fn default() -> Self {
+ Self {
+ row_height: 18.0,
+ gap: 6.0,
+ rounding: 3.0,
+ show_values: true,
+ value_precision: 0,
+ }
+ }
+}
+
+/// Draws a horizontal bar chart. Returns the combined response so you can check hover/click if desired.
+pub fn horizontal_bar_chart(
+ ui: &mut Ui,
+ title: Option<&str>,
+ bars: &[Bar],
+ style: BarChartStyle,
+) -> Response {
+ if let Some(t) = title {
+ ui.label(t);
+ }
+
+ if bars.is_empty() {
+ return ui.label("No data");
+ }
+
+ let max_v = bars
+ .iter()
+ .map(|b| b.value.max(0.0))
+ .fold(0.0_f32, f32::max);
+
+ if max_v <= 0.0 {
+ return ui.label("No data");
+ }
+
+ // Layout: label column + bar column
+ let label_col_w = ui
+ .fonts(|f| {
+ bars.iter()
+ .map(|b| {
+ f.layout_no_wrap(
+ b.label.to_owned(),
+ FontId::proportional(14.0),
+ ui.visuals().text_color(),
+ )
+ .size()
+ .x
+ })
+ .fold(0.0, f32::max)
+ })
+ .ceil()
+ + 10.0;
+
+ let avail_w = ui.available_width().max(50.0);
+ let bar_col_w = (avail_w - label_col_w).max(50.0);
+
+ let total_h =
+ bars.len() as f32 * style.row_height + (bars.len().saturating_sub(1) as f32) * style.gap;
+ let (outer_rect, outer_resp) =
+ ui.allocate_exact_size(Vec2::new(avail_w, total_h), Sense::hover());
+ let painter = ui.painter_at(outer_rect);
+
+ // Optional: faint background
+ painter.rect_filled(outer_rect, 6.0, ui.visuals().faint_bg_color);
+
+ let mut y = outer_rect.top();
+
+ for b in bars {
+ let row_rect = Rect::from_min_size(
+ Pos2::new(outer_rect.left(), y),
+ Vec2::new(avail_w, style.row_height),
+ );
+ let row_resp = ui.interact(
+ row_rect,
+ ui.id().with(&b.label).with(y as i64),
+ Sense::hover(),
+ );
+
+ // Label (left)
+ let label_pos = Pos2::new(row_rect.left() + 6.0, row_rect.center().y);
+ painter.text(
+ label_pos,
+ Align2::LEFT_CENTER,
+ &b.label,
+ FontId::proportional(14.0),
+ ui.visuals().text_color(),
+ );
+
+ // Bar background track (right)
+ let track_rect = Rect::from_min_max(
+ Pos2::new(row_rect.left() + label_col_w, row_rect.top() + 2.0),
+ Pos2::new(
+ row_rect.left() + label_col_w + bar_col_w,
+ row_rect.bottom() - 2.0,
+ ),
+ );
+ painter.rect_filled(
+ track_rect,
+ style.rounding,
+ ui.visuals().widgets.inactive.bg_fill,
+ );
+ painter.rect_stroke(
+ track_rect,
+ style.rounding,
+ Stroke::new(1.0, ui.visuals().widgets.inactive.bg_stroke.color),
+ StrokeKind::Middle,
+ );
+
+ // Filled portion
+ let frac = (b.value.max(0.0) / max_v).clamp(0.0, 1.0);
+ let fill_w = track_rect.width() * frac;
+ if fill_w > 0.0 {
+ let fill_rect = Rect::from_min_max(
+ track_rect.min,
+ Pos2::new(track_rect.min.x + fill_w, track_rect.max.y),
+ );
+ painter.rect_filled(fill_rect, style.rounding, b.color);
+ }
+
+ // Value label (right-aligned at end of track)
+ if style.show_values {
+ let txt = if style.value_precision == 0 {
+ format!("{:.0}", b.value)
+ } else {
+ format!("{:.*}", style.value_precision, b.value)
+ };
+ painter.text(
+ Pos2::new(track_rect.right() - 6.0, row_rect.center().y),
+ Align2::RIGHT_CENTER,
+ txt,
+ FontId::proportional(13.0),
+ ui.visuals().text_color(),
+ );
+ }
+
+ // Tooltip on hover
+ if row_resp.hovered() {
+ let sum = bars.iter().map(|x| x.value.max(0.0)).sum::<f32>().max(1.0);
+ let pct = (b.value / sum) * 100.0;
+ row_resp.on_hover_text(format!("{}: {:.0} ({:.1}%)", b.label, b.value, pct));
+ }
+
+ y += style.row_height + style.gap;
+ }
+
+ outer_resp
+}
diff --git a/crates/notedeck_dashboard/src/lib.rs b/crates/notedeck_dashboard/src/lib.rs
@@ -0,0 +1,370 @@
+use std::collections::HashMap;
+use std::thread;
+use std::time::{Duration, Instant};
+
+use crossbeam_channel as chan;
+
+use nostrdb::{Filter, Ndb, Transaction};
+use notedeck::{AppContext, AppResponse, try_process_events_core};
+
+mod chart;
+mod ui;
+
+// ----------------------
+// Worker protocol
+// ----------------------
+
+#[derive(Debug)]
+enum WorkerCmd {
+ Refresh,
+ //Quit,
+}
+
+#[derive(Clone, Debug, Default)]
+struct DashboardState {
+ total_count: usize,
+ top_kinds: Vec<(u32, u64)>,
+}
+
+#[derive(Debug, Clone)]
+struct Snapshot {
+ started_at: Instant,
+ snapshot_at: Instant,
+ state: DashboardState,
+}
+
+#[derive(Debug)]
+enum WorkerMsg {
+ Snapshot(Snapshot),
+ Finished {
+ started_at: Instant,
+ finished_at: Instant,
+ state: DashboardState,
+ },
+ Failed {
+ started_at: Instant,
+ finished_at: Instant,
+ error: String,
+ },
+}
+
+// ----------------------
+// Dashboard (single pass, single worker)
+// ----------------------
+
+pub struct Dashboard {
+ initialized: bool,
+
+ // Worker channels
+ cmd_tx: Option<chan::Sender<WorkerCmd>>,
+ msg_rx: Option<chan::Receiver<WorkerMsg>>,
+
+ // Refresh policy
+ refresh_every: Duration,
+ next_tick: Instant,
+
+ // UI state (progressively filled via snapshots)
+ running: bool,
+ last_started: Option<Instant>,
+ last_snapshot: Option<Instant>,
+ last_finished: Option<Instant>,
+ last_duration: Option<Duration>,
+ last_error: Option<String>,
+
+ state: DashboardState,
+}
+
+impl Default for Dashboard {
+ fn default() -> Self {
+ Self {
+ initialized: false,
+
+ cmd_tx: None,
+ msg_rx: None,
+
+ refresh_every: Duration::from_secs(10),
+ next_tick: Instant::now(),
+
+ running: false,
+ last_started: None,
+ last_snapshot: None,
+ last_finished: None,
+ last_duration: None,
+ last_error: None,
+
+ state: DashboardState::default(),
+ }
+ }
+}
+
+impl notedeck::App for Dashboard {
+ fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
+ try_process_events_core(ctx, ui.ctx(), |_, _| {});
+
+ if !self.initialized {
+ self.initialized = true;
+ self.init(ctx);
+ }
+
+ self.process_worker_msgs(ui.ctx());
+ self.schedule_refresh();
+
+ self.show(ui);
+
+ AppResponse::none()
+ }
+}
+
+impl Dashboard {
+ fn init(&mut self, ctx: &mut AppContext<'_>) {
+ // spawn single worker thread and keep it alive
+ let (cmd_tx, cmd_rx) = chan::unbounded::<WorkerCmd>();
+ let (msg_tx, msg_rx) = chan::unbounded::<WorkerMsg>();
+
+ self.cmd_tx = Some(cmd_tx.clone());
+ self.msg_rx = Some(msg_rx);
+
+ // Clone the DB handle into the worker thread (Ndb is typically cheap/cloneable)
+ let ndb = ctx.ndb.clone();
+
+ spawn_worker(ndb, cmd_rx, msg_tx);
+
+ // kick the first run immediately
+ let _ = cmd_tx.send(WorkerCmd::Refresh);
+ self.running = true;
+ self.last_error = None;
+ self.last_started = Some(Instant::now());
+ self.last_snapshot = None;
+ self.last_finished = None;
+ self.last_duration = None;
+ self.state = DashboardState::default();
+ }
+
+ fn process_worker_msgs(&mut self, egui_ctx: &egui::Context) {
+ let Some(rx) = &self.msg_rx else { return };
+
+ let mut got_any = false;
+
+ while let Ok(msg) = rx.try_recv() {
+ got_any = true;
+ match msg {
+ WorkerMsg::Snapshot(s) => {
+ self.running = true;
+ self.last_started = Some(s.started_at);
+ self.last_snapshot = Some(s.snapshot_at);
+ self.last_error = None;
+
+ self.state = s.state;
+
+ // Push UI updates with no "loading screen"
+ egui_ctx.request_repaint();
+ }
+ WorkerMsg::Finished {
+ started_at,
+ finished_at,
+ state,
+ } => {
+ self.running = false;
+ self.last_started = Some(started_at);
+ self.last_snapshot = Some(finished_at);
+ self.last_finished = Some(finished_at);
+ self.last_duration = Some(finished_at.saturating_duration_since(started_at));
+ self.last_error = None;
+
+ self.state = state;
+
+ egui_ctx.request_repaint();
+ }
+ WorkerMsg::Failed {
+ started_at,
+ finished_at,
+ error,
+ } => {
+ self.running = false;
+ self.last_started = Some(started_at);
+ self.last_snapshot = Some(finished_at);
+ self.last_finished = Some(finished_at);
+ self.last_duration = Some(finished_at.saturating_duration_since(started_at));
+ self.last_error = Some(error);
+
+ egui_ctx.request_repaint();
+ }
+ }
+ }
+
+ if got_any {
+ // No-op; we already requested repaint on every message.
+ }
+ }
+
+ fn schedule_refresh(&mut self) {
+ // throttle scheduling checks a bit
+ let now = Instant::now();
+ if now < self.next_tick {
+ return;
+ }
+ self.next_tick = now + Duration::from_millis(200);
+
+ if self.running {
+ return;
+ }
+
+ // refresh every 30 seconds from the last finished time (or from init)
+ let last = self
+ .last_finished
+ .or(self.last_started)
+ .unwrap_or_else(Instant::now);
+
+ if now.saturating_duration_since(last) >= self.refresh_every
+ && let Some(tx) = &self.cmd_tx
+ {
+ // reset UI fields for progressive load, but keep old values visible until snapshots arrive
+ self.running = true;
+ self.last_error = None;
+ self.last_started = Some(now);
+ self.last_snapshot = None;
+ self.last_finished = None;
+ self.last_duration = None;
+ self.state = DashboardState::default();
+
+ let _ = tx.send(WorkerCmd::Refresh);
+ }
+ }
+
+ fn show(&mut self, ui: &mut egui::Ui) {
+ egui::Frame::new()
+ .inner_margin(egui::Margin::same(20))
+ .show(ui, |ui| {
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ self.grid(ui);
+ });
+ });
+ }
+
+ fn grid(&mut self, ui: &mut egui::Ui) {
+ let cols = 2;
+ let min_card = 240.0;
+
+ egui::Grid::new("dashboard_grid_single_worker")
+ .num_columns(cols)
+ .min_col_width(min_card)
+ .spacing(egui::vec2(8.0, 8.0))
+ .show(ui, |ui| {
+ use crate::ui::{card_ui, kinds_ui, totals_ui};
+
+ // Card 1: Total notes
+ card_ui(ui, min_card, |ui| {
+ totals_ui(self, ui);
+ });
+
+ // Card 2: Kinds (top)
+ card_ui(ui, min_card, |ui| {
+ kinds_ui(self, ui);
+ });
+
+ ui.end_row();
+ });
+ }
+}
+
+// ----------------------
+// Worker side (single pass, periodic snapshots)
+// ----------------------
+
+fn spawn_worker(ndb: Ndb, cmd_rx: chan::Receiver<WorkerCmd>, msg_tx: chan::Sender<WorkerMsg>) {
+ thread::Builder::new()
+ .name("dashboard-worker".to_owned())
+ .spawn(move || {
+ let mut should_quit = false;
+
+ while !should_quit {
+ match cmd_rx.recv() {
+ Ok(WorkerCmd::Refresh) => {
+ let started_at = Instant::now();
+
+ match materialize_single_pass(&ndb, &msg_tx, started_at) {
+ Ok(state) => {
+ let _ = msg_tx.send(WorkerMsg::Finished {
+ started_at,
+ finished_at: Instant::now(),
+ state,
+ });
+ }
+ Err(e) => {
+ let _ = msg_tx.send(WorkerMsg::Failed {
+ started_at,
+ finished_at: Instant::now(),
+ error: format!("{e:?}"),
+ });
+ }
+ }
+ }
+ Err(_) => {
+ should_quit = true;
+ }
+ }
+ }
+ })
+ .expect("failed to spawn dashboard worker thread");
+}
+
+fn materialize_single_pass(
+ ndb: &Ndb,
+ msg_tx: &chan::Sender<WorkerMsg>,
+ started_at: Instant,
+) -> Result<DashboardState, nostrdb::Error> {
+ // one transaction per refresh run
+ let txn = Transaction::new(ndb)?;
+
+ // all notes
+ let filters = vec![Filter::new_with_capacity(1).build()];
+
+ struct Acc {
+ total_count: usize,
+ kinds: HashMap<u32, u64>,
+ last_emit: Instant,
+ }
+
+ let mut acc = Acc {
+ total_count: 0,
+ kinds: HashMap::new(),
+ last_emit: Instant::now(),
+ };
+
+ let emit_every = Duration::from_millis(32);
+
+ let _ = ndb.fold(&txn, &filters, &mut acc, |acc, note| {
+ acc.total_count += 1;
+
+ *acc.kinds.entry(note.kind()).or_default() += 1;
+
+ let now = Instant::now();
+ if now.saturating_duration_since(acc.last_emit) >= emit_every {
+ acc.last_emit = now;
+
+ let top = top_kinds(&acc.kinds, 6);
+ let _ = msg_tx.send(WorkerMsg::Snapshot(Snapshot {
+ started_at,
+ snapshot_at: now,
+ state: DashboardState {
+ total_count: acc.total_count,
+ top_kinds: top,
+ },
+ }));
+ }
+
+ acc
+ });
+
+ Ok(DashboardState {
+ total_count: acc.total_count,
+ top_kinds: top_kinds(&acc.kinds, 6),
+ })
+}
+
+fn top_kinds(hmap: &HashMap<u32, u64>, limit: usize) -> Vec<(u32, u64)> {
+ let mut v: Vec<(u32, u64)> = hmap.iter().map(|(k, c)| (*k, *c)).collect();
+ v.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
+ v.truncate(limit);
+ v
+}
diff --git a/crates/notedeck_dashboard/src/ui.rs b/crates/notedeck_dashboard/src/ui.rs
@@ -0,0 +1,125 @@
+use egui::FontId;
+use egui::RichText;
+
+use std::time::Duration;
+use std::time::Instant;
+
+use crate::Dashboard;
+use crate::chart::Bar;
+use crate::chart::BarChartStyle;
+use crate::chart::horizontal_bar_chart;
+use crate::chart::palette;
+
+pub fn footer_status_ui(
+ ui: &mut egui::Ui,
+ running: bool,
+ err: Option<&str>,
+ last_snapshot: Option<Instant>,
+ last_duration: Option<Duration>,
+) {
+ ui.add_space(8.0);
+
+ if let Some(e) = err {
+ ui.label(RichText::new(e).color(ui.visuals().error_fg_color).small());
+ return;
+ }
+
+ let mut parts: Vec<String> = Vec::new();
+ if running {
+ parts.push("updating…".to_owned());
+ }
+
+ if let Some(t) = last_snapshot {
+ parts.push(format!(
+ "updated {:.1?} ago",
+ Instant::now().duration_since(t)
+ ));
+ }
+
+ if let Some(d) = last_duration {
+ let ms = d.as_secs_f64() * 1000.0;
+ parts.push(format!("{ms:.0} ms"));
+ }
+
+ if parts.is_empty() {
+ parts.push("—".to_owned());
+ }
+
+ ui.label(RichText::new(parts.join(" · ")).small().weak());
+}
+
+fn card_header_ui(ui: &mut egui::Ui, title: &str) {
+ ui.horizontal(|ui| {
+ let weak = ui.visuals().weak_text_color();
+ ui.add(
+ egui::Label::new(egui::RichText::new(title).small().color(weak))
+ .wrap_mode(egui::TextWrapMode::Wrap),
+ );
+ });
+}
+
+pub fn card_ui(ui: &mut egui::Ui, min_card: f32, content: impl FnOnce(&mut egui::Ui)) {
+ let visuals = ui.visuals().clone();
+
+ egui::Frame::group(ui.style())
+ .fill(visuals.extreme_bg_color)
+ .corner_radius(egui::CornerRadius::same(12))
+ .inner_margin(egui::Margin::same(12))
+ .stroke(egui::Stroke::new(
+ 1.0,
+ visuals.widgets.noninteractive.bg_stroke.color,
+ ))
+ .show(ui, |ui| {
+ ui.set_min_width(min_card);
+ ui.set_min_height(min_card * 0.5);
+ ui.vertical(|ui| content(ui));
+ });
+}
+
+pub fn kinds_ui(dashboard: &Dashboard, ui: &mut egui::Ui) {
+ card_header_ui(ui, "Kinds");
+ ui.add_space(8.0);
+
+ let bars = kinds_to_bars(&dashboard.state.top_kinds);
+ if bars.is_empty() && dashboard.state.total_count == 0 && dashboard.last_error.is_none() {
+ // still show something (no loading screen)
+ ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak());
+ } else {
+ horizontal_bar_chart(ui, None, &bars, BarChartStyle::default());
+ }
+
+ footer_status_ui(
+ ui,
+ dashboard.running,
+ dashboard.last_error.as_deref(),
+ dashboard.last_snapshot,
+ dashboard.last_duration,
+ );
+}
+
+pub fn totals_ui(dashboard: &Dashboard, ui: &mut egui::Ui) {
+ card_header_ui(ui, "All notes");
+ ui.add_space(8.0);
+
+ ui.horizontal(|ui| {
+ ui.label(
+ RichText::new(dashboard.state.total_count.to_string())
+ .font(FontId::proportional(34.0))
+ .strong(),
+ );
+
+ ui.add_space(10.0);
+ });
+}
+
+fn kinds_to_bars(top_kinds: &[(u32, u64)]) -> Vec<Bar> {
+ top_kinds
+ .iter()
+ .enumerate()
+ .map(|(i, (k, c))| Bar {
+ label: format!("{k}"),
+ value: *c as f32,
+ color: palette(i),
+ })
+ .collect()
+}
diff --git a/shell.nix b/shell.nix
@@ -14,6 +14,7 @@ mkShell ({
#cargo-edit
#cargo-watch
rustup
+ gdb
libiconv
pkg-config
cmake