notedeck

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

commit fc509b1b264cd01f14cb1ef974f708489acc1e80
parent 1fd92e9e00cc2031b7cb8503aa5bc54a57731927
Author: William Casarin <jb55@jb55.com>
Date:   Fri,  8 Aug 2025 17:22:51 -0700

clndash: channels ui

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

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

diff --git a/crates/notedeck_clndash/src/lib.rs b/crates/notedeck_clndash/src/lib.rs @@ -2,21 +2,31 @@ use egui::{Color32, Label, RichText}; use lnsocket::bitcoin::secp256k1::{PublicKey, SecretKey, rand}; use lnsocket::{CommandoClient, LNSocket}; use notedeck::{AppAction, AppContext}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::HashMap; use std::str::FromStr; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; -type JsonCache = HashMap<String, String>; +struct Channel { + to_us: i64, + to_them: i64, + original: ListPeerChannel, +} + +struct Channels { + max_total_msat: i64, + avail_in: i64, + avail_out: i64, + channels: Vec<Channel>, +} #[derive(Default)] pub struct ClnDash { initialized: bool, connection_state: ConnectionState, get_info: Option<String>, - peer_channels: Option<Vec<Value>>, - json_cache: JsonCache, - channel: Option<Channel>, + channels: Option<Channels>, + channel: Option<CommChannel>, } impl Default for ConnectionState { @@ -25,7 +35,7 @@ impl Default for ConnectionState { } } -struct Channel { +struct CommChannel { req_tx: UnboundedSender<Request>, event_rx: UnboundedReceiver<Event>, } @@ -33,7 +43,16 @@ struct Channel { /// Responses from the socket enum ClnResponse { GetInfo(Value), - ListPeerChannels(Value), + ListPeerChannels(Channels), +} + +#[derive(Deserialize, Serialize)] +struct ListPeerChannel { + short_channel_id: String, + our_reserve_msat: i64, + to_us_msat: i64, + total_msat: i64, + their_reserve_msat: i64, } enum ConnectionState { @@ -98,12 +117,11 @@ fn connection_state_ui(ui: &mut egui::Ui, state: &ConnectionState) { impl ClnDash { fn show(&mut self, ui: &mut egui::Ui) { egui::Frame::new() - .inner_margin(egui::Margin::same(50)) + .inner_margin(egui::Margin::same(20)) .show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { connection_state_ui(ui, &self.connection_state); - - channels_ui(ui, &mut self.json_cache, &self.peer_channels); + channels_ui(ui, &self.channels); if let Some(info) = self.get_info.as_ref() { get_info_ui(ui, info); @@ -115,7 +133,7 @@ impl ClnDash { fn setup_connection(&mut self) { let (req_tx, mut req_rx) = unbounded_channel::<Request>(); let (event_tx, event_rx) = unbounded_channel::<Event>(); - self.channel = Some(Channel { req_tx, event_rx }); + self.channel = Some(CommChannel { req_tx, event_rx }); tokio::spawn(async move { let key = SecretKey::new(&mut rand::thread_rng()); @@ -168,8 +186,12 @@ impl ClnDash { Request::ListPeerChannels => { match commando.call("listpeerchannels", json!({})).await { Ok(v) => { + let peer_channels: Vec<ListPeerChannel> = + serde_json::from_value(v["channels"].clone()).unwrap(); let _ = event_tx.send(Event::Response( - ClnResponse::ListPeerChannels(v), + ClnResponse::ListPeerChannels(to_channels( + peer_channels, + )), )); } Err(err) => { @@ -203,9 +225,7 @@ impl ClnDash { Event::Response(resp) => match resp { ClnResponse::ListPeerChannels(chans) => { - if let Some(vs) = chans["channels"].as_array() { - self.peer_channels = Some(vs.to_owned()); - } + self.channels = Some(chans); } ClnResponse::GetInfo(value) => { @@ -225,27 +245,140 @@ fn get_info_ui(ui: &mut egui::Ui, info: &str) { }); } -fn channel_ui(ui: &mut egui::Ui, cache: &mut JsonCache, channel: &Value) { - let short_channel_id = channel["short_channel_id"].as_str().unwrap_or("??"); +fn channel_ui(ui: &mut egui::Ui, c: &Channel, max_total_msat: i64) { + // ---------- numbers ---------- + let short_channel_id = &c.original.short_channel_id; + + let cap_ratio = (c.original.total_msat as f32 / max_total_msat.max(1) as f32).clamp(0.0, 1.0); + // Feel free to switch to log scaling if you have whales: + //let cap_ratio = ((c.original.total_msat as f32 + 1.0).log10() / (max_total_msat as f32 + 1.0).log10()).clamp(0.0, 1.0); + + // ---------- colors & style ---------- + let out_color = Color32::from_rgb(84, 69, 201); // blue + let in_color = Color32::from_rgb(158, 56, 180); // purple + + // Thickness scales with capacity, but keeps a nice minimum + let thickness = 10.0 + cap_ratio * 22.0; // 10 → 32 px + let row_h = thickness + 14.0; + + // ---------- layout ---------- + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), row_h), + egui::Sense::hover(), + ); + let painter = ui.painter_at(rect); + + let bar_rect = egui::Rect::from_min_max( + egui::pos2(rect.left(), rect.center().y - thickness * 0.5), + egui::pos2(rect.right(), rect.center().y + thickness * 0.5), + ); + let corner_radius = (thickness * 0.5) as u8; + let out_radius = egui::CornerRadius { + ne: 0, + nw: corner_radius, + sw: corner_radius, + se: 0, + }; + let in_radius = egui::CornerRadius { + ne: corner_radius, + nw: 0, + sw: 0, + se: corner_radius, + }; + /* + painter.rect_filled(bar_rect, rounding, track_color); + painter.rect_stroke(bar_rect, rounding, track_stroke, egui::StrokeKind::Middle); + */ + + // Split widths + let usable = (c.to_us + c.to_them).max(1) as f32; + let out_w = (bar_rect.width() * (c.to_us as f32 / usable)).round(); + let split_x = bar_rect.left() + out_w; + + // Outbound fill (left) + let out_rect = egui::Rect::from_min_max(bar_rect.min, egui::pos2(split_x, bar_rect.max.y)); + if out_rect.width() > 0.5 { + painter.rect_filled(out_rect, out_radius, out_color); + } - egui::CollapsingHeader::new(format!("channel {short_channel_id}")) - .id_salt(("section", short_channel_id)) - .show(ui, |ui| { - let json: &String = cache - .entry(short_channel_id.to_owned()) - .or_insert_with(|| serde_json::to_string_pretty(channel).unwrap()); + // Inbound fill (right) + let in_rect = egui::Rect::from_min_max(egui::pos2(split_x, bar_rect.min.y), bar_rect.max); + if in_rect.width() > 0.5 { + painter.rect_filled(in_rect, in_radius, in_color); + } - ui.add(Label::new(json).wrap_mode(egui::TextWrapMode::Wrap)); - }); + // Tooltip + response.on_hover_text_at_pointer(format!( + "Channel ID {short_channel_id}\nOutbound (ours): {} sats\nInbound (theirs): {} sats\nCapacity: {} sats", + human_sat(c.to_us), + human_sat(c.to_them), + human_sat(c.original.total_msat), + )); } -fn channels_ui(ui: &mut egui::Ui, json_cache: &mut JsonCache, channels: &Option<Vec<Value>>) { +// ---------- helper ---------- +fn human_sat(msat: i64) -> String { + let sats = msat / 1000; + if sats >= 1_000_000 { + format!("{:.1}M", sats as f64 / 1_000_000.0) + } else if sats >= 1_000 { + format!("{:.1}k", sats as f64 / 1_000.0) + } else { + sats.to_string() + } +} + +fn channels_ui(ui: &mut egui::Ui, channels: &Option<Channels>) { let Some(channels) = channels else { ui.label("no channels"); return; }; - for channel in channels { - channel_ui(ui, json_cache, channel); + for channel in &channels.channels { + channel_ui(ui, channel, channels.max_total_msat); + } + + ui.label(format!("available out {}", human_sat(channels.avail_out))); + ui.label(format!("available in {}", human_sat(channels.avail_in))); +} + +fn to_channels(peer_channels: Vec<ListPeerChannel>) -> Channels { + let mut avail_out: i64 = 0; + let mut avail_in: i64 = 0; + let mut max_total_msat: i64 = 0; + + let mut channels: Vec<Channel> = peer_channels + .into_iter() + .map(|c| { + let to_us = (c.to_us_msat - c.our_reserve_msat).max(0); + let to_them_raw = (c.total_msat - c.to_us_msat).max(0); + let to_them = (to_them_raw - c.their_reserve_msat).max(0); + + avail_out += to_us; + avail_in += to_them; + if c.total_msat > max_total_msat { + max_total_msat = c.total_msat; // <-- max, not sum + } + + Channel { + to_us, + to_them, + original: c, + } + }) + .collect(); + + channels.sort_by(|a, b| { + let a_capacity = a.to_them + a.to_us; + let b_capacity = b.to_them + b.to_us; + + a_capacity.partial_cmp(&b_capacity).unwrap().reverse() + }); + + Channels { + max_total_msat, + avail_out, + avail_in, + channels, } }