notedeck

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

commit 2444e24fb5463f5299e1b5defe3e5cfd389f3f02
parent fc509b1b264cd01f14cb1ef974f708489acc1e80
Author: William Casarin <jb55@jb55.com>
Date:   Fri,  8 Aug 2025 19:15:03 -0700

clndash: summary cards

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/notedeck_clndash/src/lib.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 137 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_clndash/src/lib.rs b/crates/notedeck_clndash/src/lib.rs @@ -27,6 +27,7 @@ pub struct ClnDash { get_info: Option<String>, channels: Option<Channels>, channel: Option<CommChannel>, + last_summary: Option<Summary>, } impl Default for ConnectionState { @@ -82,6 +83,7 @@ impl notedeck::App for ClnDash { fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { if !self.initialized { self.connection_state = ConnectionState::Connecting; + self.setup_connection(); self.initialized = true; } @@ -121,6 +123,11 @@ impl ClnDash { .show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { connection_state_ui(ui, &self.connection_state); + if let Some(ch) = self.channels.as_ref() { + let summary = compute_summary(ch); + summary_cards_ui(ui, &summary, self.last_summary.as_ref()); + ui.add_space(8.0); + } channels_ui(ui, &self.channels); if let Some(info) = self.get_info.as_ref() { @@ -225,6 +232,9 @@ impl ClnDash { Event::Response(resp) => match resp { ClnResponse::ListPeerChannels(chans) => { + if let Some(prev) = self.channels.as_ref() { + self.last_summary = Some(compute_summary(prev)); + } self.channels = Some(chans); } @@ -382,3 +392,130 @@ fn to_channels(peer_channels: Vec<ListPeerChannel>) -> Channels { channels, } } + +fn summary_cards_ui(ui: &mut egui::Ui, s: &Summary, prev: Option<&Summary>) { + let old = prev.cloned().unwrap_or_default(); + let items: [(&str, String, Option<String>); 6] = [ + ( + "Total capacity", + human_sat(s.total_msat), + prev.map(|_| delta_str(s.total_msat, old.total_msat)), + ), + ( + "Avail out", + human_sat(s.avail_out_msat), + prev.map(|_| delta_str(s.avail_out_msat, old.avail_out_msat)), + ), + ( + "Avail in", + human_sat(s.avail_in_msat), + prev.map(|_| delta_str(s.avail_in_msat, old.avail_in_msat)), + ), + ("# Channels", s.channel_count.to_string(), None), + ("Largest", human_sat(s.largest_msat), None), + ( + "Outbound %", + format!("{:.0}%", s.outbound_pct * 100.0), + None, + ), + ]; + + // --- responsive columns --- + let min_card = 160.0; + let cols = ((ui.available_width() / min_card).floor() as usize).max(1); + + egui::Grid::new("summary_grid") + .num_columns(cols) + .min_col_width(min_card) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + let items_len = items.len(); + for (i, (t, v, d)) in items.into_iter().enumerate() { + card_cell(ui, t, v, d, min_card); + + // End the row when we filled a row worth of cells + if (i + 1) % cols == 0 { + ui.end_row(); + } + } + + // If the last row wasn't full, close it anyway + if items_len % cols != 0 { + ui.end_row(); + } + }); +} + +fn card_cell(ui: &mut egui::Ui, title: &str, value: String, delta: Option<String>, min_card: f32) { + let weak = ui.visuals().weak_text_color(); + egui::Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::same(10)) + .stroke(ui.visuals().widgets.noninteractive.bg_stroke) + .show(ui, |ui| { + ui.set_min_width(min_card); + ui.vertical(|ui| { + ui.add( + egui::Label::new(egui::RichText::new(title).small().color(weak)) + .wrap_mode(egui::TextWrapMode::Wrap), + ); + ui.add_space(4.0); + ui.add( + egui::Label::new(egui::RichText::new(value).strong().size(18.0)) + .wrap_mode(egui::TextWrapMode::Wrap), + ); + if let Some(d) = delta { + ui.add_space(2.0); + ui.add( + egui::Label::new(egui::RichText::new(d).small().color(weak)) + .wrap_mode(egui::TextWrapMode::Wrap), + ); + } + }); + ui.set_min_height(20.0); + }); +} + +#[derive(Clone, Default)] +struct Summary { + total_msat: i64, + avail_out_msat: i64, + avail_in_msat: i64, + channel_count: usize, + largest_msat: i64, + outbound_pct: f32, // fraction of total capacity +} + +fn compute_summary(ch: &Channels) -> Summary { + let total_msat: i64 = ch.channels.iter().map(|c| c.original.total_msat).sum(); + let largest_msat: i64 = ch + .channels + .iter() + .map(|c| c.original.total_msat) + .max() + .unwrap_or(0); + let outbound_pct = if total_msat > 0 { + ch.avail_out as f32 / total_msat as f32 + } else { + 0.0 + }; + + Summary { + total_msat, + avail_out_msat: ch.avail_out, + avail_in_msat: ch.avail_in, + channel_count: ch.channels.len(), + largest_msat, + outbound_pct, + } +} + +fn delta_str(new: i64, old: i64) -> String { + let d = new - old; + match d.cmp(&0) { + std::cmp::Ordering::Greater => format!("↑ {}", human_sat(d)), + std::cmp::Ordering::Less => format!("↓ {}", human_sat(-d)), + std::cmp::Ordering::Equal => "·".into(), + } +}