commit 35e9354217695a1ecb535d056ddcae7a14a19d92
parent 08a97c946d9a6af7ee36f837d851a5aaf5adad14
Author: William Casarin <jb55@jb55.com>
Date: Mon, 11 Aug 2025 10:36:44 -0700
clndash: reorganize
Signed-off-by: William Casarin <jb55@jb55.com>
Diffstat:
7 files changed, 554 insertions(+), 522 deletions(-)
diff --git a/crates/notedeck_clndash/src/channels.rs b/crates/notedeck_clndash/src/channels.rs
@@ -0,0 +1,124 @@
+use crate::event::LoadingState;
+use crate::ui;
+use egui::Color32;
+use serde::{Deserialize, Serialize};
+
+#[derive(Deserialize, Serialize)]
+pub struct ListPeerChannel {
+ pub short_channel_id: String,
+ pub our_reserve_msat: i64,
+ pub to_us_msat: i64,
+ pub total_msat: i64,
+ pub their_reserve_msat: i64,
+}
+
+pub struct Channel {
+ pub to_us: i64,
+ pub to_them: i64,
+ pub original: ListPeerChannel,
+}
+
+pub struct Channels {
+ pub max_total_msat: i64,
+ pub avail_in: i64,
+ pub avail_out: i64,
+ pub channels: Vec<Channel>,
+}
+
+pub fn channels_ui(ui: &mut egui::Ui, channels: &LoadingState<Channels, lnsocket::Error>) {
+ match channels {
+ LoadingState::Loaded(channels) => {
+ if channels.channels.is_empty() {
+ ui.label("no channels yet...");
+ return;
+ }
+
+ for channel in &channels.channels {
+ channel_ui(ui, channel, channels.max_total_msat);
+ }
+
+ ui.label(format!(
+ "available out {}",
+ ui::human_sat(channels.avail_out)
+ ));
+ ui.label(format!("available in {}", ui::human_sat(channels.avail_in)));
+ }
+ LoadingState::Failed(err) => {
+ ui.label(format!("error fetching channels: {err}"));
+ }
+ LoadingState::Loading => {
+ ui.label("fetching channels...");
+ }
+ }
+}
+
+pub 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);
+ }
+
+ // 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);
+ }
+
+ // Tooltip
+ response.on_hover_text_at_pointer(format!(
+ "Channel ID {short_channel_id}\nOutbound (ours): {} sats\nInbound (theirs): {} sats\nCapacity: {} sats",
+ ui::human_sat(c.to_us),
+ ui::human_sat(c.to_them),
+ ui::human_sat(c.original.total_msat),
+ ));
+}
diff --git a/crates/notedeck_clndash/src/event.rs b/crates/notedeck_clndash/src/event.rs
@@ -1,7 +1,52 @@
-use lightning_invoice::Bolt11Invoice;
-use serde::{Deserialize, Serialize};
+use crate::channels::Channels;
+use crate::invoice::Invoice;
+use serde::Serialize;
use serde_json::Value;
+pub enum ConnectionState {
+ Dead(String),
+ Connecting,
+ Active,
+}
+pub enum LoadingState<T, E> {
+ Loading,
+ Failed(E),
+ Loaded(T),
+}
+
+impl<T, E> Default for LoadingState<T, E> {
+ fn default() -> Self {
+ Self::Loading
+ }
+}
+
+impl<T, E> LoadingState<T, E> {
+ fn _as_ref(&self) -> LoadingState<&T, &E> {
+ match self {
+ Self::Loading => LoadingState::<&T, &E>::Loading,
+ Self::Failed(err) => LoadingState::<&T, &E>::Failed(err),
+ Self::Loaded(t) => LoadingState::<&T, &E>::Loaded(t),
+ }
+ }
+
+ pub fn from_result(res: Result<T, E>) -> LoadingState<T, E> {
+ match res {
+ Ok(r) => LoadingState::Loaded(r),
+ Err(err) => LoadingState::Failed(err),
+ }
+ }
+
+ /*
+ fn unwrap(self) -> T {
+ let Self::Loaded(t) = self else {
+ panic!("unwrap in LoadingState");
+ };
+
+ t
+ }
+ */
+}
+
#[derive(Serialize, Debug, Clone)]
pub struct WaitRequest {
pub indexname: String,
@@ -16,42 +61,6 @@ pub enum Request {
PaidInvoices(u32),
}
-#[derive(Deserialize, Serialize)]
-pub struct ListPeerChannel {
- pub short_channel_id: String,
- pub our_reserve_msat: i64,
- pub to_us_msat: i64,
- pub total_msat: i64,
- pub their_reserve_msat: i64,
-}
-
-pub struct Channel {
- pub to_us: i64,
- pub to_them: i64,
- pub original: ListPeerChannel,
-}
-
-pub struct Channels {
- pub max_total_msat: i64,
- pub avail_in: i64,
- pub avail_out: i64,
- pub channels: Vec<Channel>,
-}
-
-#[derive(Debug, Deserialize, Serialize)]
-pub struct Invoice {
- pub lastpay_index: Option<u64>,
- pub label: String,
- pub bolt11: Bolt11Invoice,
- pub payment_hash: String,
- pub amount_msat: u64,
- pub status: String,
- pub description: String,
- pub expires_at: u64,
- pub created_index: u64,
- pub updated_index: u64,
-}
-
/// Responses from the socket
pub enum ClnResponse {
GetInfo(Value),
diff --git a/crates/notedeck_clndash/src/invoice.rs b/crates/notedeck_clndash/src/invoice.rs
@@ -0,0 +1,77 @@
+use crate::event::LoadingState;
+use crate::ui;
+use lightning_invoice::Bolt11Invoice;
+use notedeck::AppContext;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Invoice {
+ pub lastpay_index: Option<u64>,
+ pub label: String,
+ pub bolt11: Bolt11Invoice,
+ pub payment_hash: String,
+ pub amount_msat: u64,
+ pub status: String,
+ pub description: String,
+ pub expires_at: u64,
+ pub created_index: u64,
+ pub updated_index: u64,
+}
+
+pub fn invoices_ui(
+ ui: &mut egui::Ui,
+ invoice_notes: &HashMap<String, [u8; 32]>,
+ ctx: &mut AppContext,
+ invoices: &LoadingState<Vec<Invoice>, lnsocket::Error>,
+) {
+ match invoices {
+ LoadingState::Loading => {
+ ui.label("loading invoices...");
+ }
+
+ LoadingState::Failed(err) => {
+ ui.label(format!("failed to load invoices: {err}"));
+ }
+
+ LoadingState::Loaded(invoices) => {
+ use egui_extras::{Column, TableBuilder};
+
+ TableBuilder::new(ui)
+ .column(Column::auto().resizable(true))
+ .column(Column::remainder())
+ .vscroll(false)
+ .header(20.0, |mut header| {
+ header.col(|ui| {
+ ui.strong("description");
+ });
+ header.col(|ui| {
+ ui.strong("amount");
+ });
+ })
+ .body(|mut body| {
+ for invoice in invoices {
+ body.row(20.0, |mut row| {
+ row.col(|ui| {
+ if invoice.description.starts_with("{") {
+ ui.label("Zap!").on_hover_ui_at_pointer(|ui| {
+ ui::note_hover_ui(ui, &invoice.label, ctx, invoice_notes);
+ });
+ } else {
+ ui.label(&invoice.description);
+ }
+ });
+ row.col(|ui| match invoice.bolt11.amount_milli_satoshis() {
+ None => {
+ ui.label("any");
+ }
+ Some(amt) => {
+ ui.label(ui::human_verbose_sat(amt as i64));
+ }
+ });
+ });
+ }
+ });
+ }
+ }
+}
diff --git a/crates/notedeck_clndash/src/lib.rs b/crates/notedeck_clndash/src/lib.rs
@@ -1,13 +1,15 @@
-use crate::event::Channel;
-use crate::event::Channels;
+use crate::channels::Channel;
+use crate::channels::Channels;
+use crate::channels::ListPeerChannel;
use crate::event::ClnResponse;
+use crate::event::ConnectionState;
use crate::event::Event;
-use crate::event::Invoice;
-use crate::event::ListPeerChannel;
+use crate::event::LoadingState;
use crate::event::Request;
+use crate::invoice::Invoice;
+use crate::summary::Summary;
use crate::watch::fetch_paid_invoices;
-use egui::{Color32, Label, RichText, Widget};
use lnsocket::bitcoin::secp256k1::{PublicKey, SecretKey, rand};
use lnsocket::{CommandoClient, LNSocket};
use nostrdb::Ndb;
@@ -18,48 +20,13 @@ use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
+mod channels;
mod event;
+mod invoice;
+mod summary;
+mod ui;
mod watch;
-pub enum LoadingState<T, E> {
- Loading,
- Failed(E),
- Loaded(T),
-}
-
-impl<T, E> Default for LoadingState<T, E> {
- fn default() -> Self {
- Self::Loading
- }
-}
-
-impl<T, E> LoadingState<T, E> {
- fn _as_ref(&self) -> LoadingState<&T, &E> {
- match self {
- Self::Loading => LoadingState::<&T, &E>::Loading,
- Self::Failed(err) => LoadingState::<&T, &E>::Failed(err),
- Self::Loaded(t) => LoadingState::<&T, &E>::Loaded(t),
- }
- }
-
- fn from_result(res: Result<T, E>) -> LoadingState<T, E> {
- match res {
- Ok(r) => LoadingState::Loaded(r),
- Err(err) => LoadingState::Failed(err),
- }
- }
-
- /*
- fn unwrap(self) -> T {
- let Self::Loaded(t) = self else {
- panic!("unwrap in LoadingState");
- };
-
- t
- }
- */
-}
-
#[derive(Default)]
pub struct ClnDash {
initialized: bool,
@@ -91,12 +58,6 @@ struct CommChannel {
event_rx: UnboundedReceiver<Event>,
}
-enum ConnectionState {
- Dead(String),
- Connecting,
- Active,
-}
-
impl notedeck::App for ClnDash {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
if !self.initialized {
@@ -114,56 +75,17 @@ impl notedeck::App for ClnDash {
}
}
-fn connection_state_ui(ui: &mut egui::Ui, state: &ConnectionState) {
- match state {
- ConnectionState::Active => {
- ui.add(Label::new(RichText::new("Connected").color(Color32::GREEN)));
- }
-
- ConnectionState::Connecting => {
- ui.add(Label::new(
- RichText::new("Connecting").color(Color32::YELLOW),
- ));
- }
-
- ConnectionState::Dead(reason) => {
- ui.add(Label::new(
- RichText::new(format!("Disconnected: {reason}")).color(Color32::RED),
- ));
- }
- }
-}
-
-fn summary_ui(
- ui: &mut egui::Ui,
- last_summary: Option<&Summary>,
- summary: &LoadingState<Summary, lnsocket::Error>,
-) {
- match summary {
- LoadingState::Loading => {
- ui.label("loading summary");
- }
- LoadingState::Failed(err) => {
- ui.label(format!("Failed to get summary: {err}"));
- }
- LoadingState::Loaded(summary) => {
- summary_cards_ui(ui, summary, last_summary);
- ui.add_space(8.0);
- }
- }
-}
-
impl ClnDash {
fn show(&mut self, ui: &mut egui::Ui, ctx: &mut AppContext) {
egui::Frame::new()
.inner_margin(egui::Margin::same(20))
.show(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
- connection_state_ui(ui, &self.connection_state);
- summary_ui(ui, self.last_summary.as_ref(), &self.summary);
- invoices_ui(ui, &self.invoice_zap_reqs, ctx, &self.invoices);
- channels_ui(ui, &self.channels);
- get_info_ui(ui, &self.get_info);
+ ui::connection_state_ui(ui, &self.connection_state);
+ crate::summary::summary_ui(ui, self.last_summary.as_ref(), &self.summary);
+ crate::invoice::invoices_ui(ui, &self.invoice_zap_reqs, ctx, &self.invoices);
+ crate::channels::channels_ui(ui, &self.channels);
+ crate::ui::get_info_ui(ui, &self.get_info);
});
});
}
@@ -282,11 +204,13 @@ impl ClnDash {
Event::Response(resp) => match resp {
ClnResponse::ListPeerChannels(chans) => {
if let LoadingState::Loaded(prev) = &self.channels {
- self.last_summary = Some(compute_summary(prev));
+ self.last_summary = Some(crate::summary::compute_summary(prev));
}
self.summary = match &chans {
- Ok(chans) => LoadingState::Loaded(compute_summary(chans)),
+ Ok(chans) => {
+ LoadingState::Loaded(crate::summary::compute_summary(chans))
+ }
Err(err) => LoadingState::Failed(err.clone()),
};
self.channels = LoadingState::from_result(chans);
@@ -324,141 +248,6 @@ impl ClnDash {
}
}
-fn get_info_ui(ui: &mut egui::Ui, info: &LoadingState<String, lnsocket::Error>) {
- ui.horizontal_wrapped(|ui| match info {
- LoadingState::Loading => {}
- LoadingState::Failed(err) => {
- ui.label(format!("failed to fetch node info: {err}"));
- }
- LoadingState::Loaded(info) => {
- ui.add(Label::new(info).wrap_mode(egui::TextWrapMode::Wrap));
- }
- });
-}
-
-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);
- }
-
- // 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);
- }
-
- // 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),
- ));
-}
-
-// ---------- 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 human_verbose_sat(msat: i64) -> String {
- if msat < 1_000 {
- // less than 1 sat
- format!("{msat} msat")
- } else {
- let sats = msat / 1_000;
- if sats < 100_000_000 {
- // less than 1 BTC
- format!("{sats} sat")
- } else {
- let btc = sats / 100_000_000;
- format!("{btc} BTC")
- }
- }
-}
-
-fn channels_ui(ui: &mut egui::Ui, channels: &LoadingState<Channels, lnsocket::Error>) {
- match channels {
- LoadingState::Loaded(channels) => {
- if channels.channels.is_empty() {
- ui.label("no channels yet...");
- return;
- }
-
- 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)));
- }
- LoadingState::Failed(err) => {
- ui.label(format!("error fetching channels: {err}"));
- }
- LoadingState::Loading => {
- ui.label("fetching channels...");
- }
- }
-}
-
fn to_channels(peer_channels: Vec<ListPeerChannel>) -> Channels {
let mut avail_out: i64 = 0;
let mut avail_in: i64 = 0;
@@ -499,255 +288,3 @@ 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(),
- }
-}
-
-fn invoices_ui(
- ui: &mut egui::Ui,
- invoice_notes: &HashMap<String, [u8; 32]>,
- ctx: &mut AppContext,
- invoices: &LoadingState<Vec<Invoice>, lnsocket::Error>,
-) {
- match invoices {
- LoadingState::Loading => {
- ui.label("loading invoices...");
- }
-
- LoadingState::Failed(err) => {
- ui.label(format!("failed to load invoices: {err}"));
- }
-
- LoadingState::Loaded(invoices) => {
- use egui_extras::{Column, TableBuilder};
-
- TableBuilder::new(ui)
- .column(Column::auto().resizable(true))
- .column(Column::remainder())
- .vscroll(false)
- .header(20.0, |mut header| {
- header.col(|ui| {
- ui.strong("description");
- });
- header.col(|ui| {
- ui.strong("amount");
- });
- })
- .body(|mut body| {
- for invoice in invoices {
- body.row(20.0, |mut row| {
- row.col(|ui| {
- if invoice.description.starts_with("{") {
- ui.label("Zap!").on_hover_ui_at_pointer(|ui| {
- note_hover_ui(ui, &invoice.label, ctx, invoice_notes);
- });
- } else {
- ui.label(&invoice.description);
- }
- });
- row.col(|ui| match invoice.bolt11.amount_milli_satoshis() {
- None => {
- ui.label("any");
- }
- Some(amt) => {
- ui.label(human_verbose_sat(amt as i64));
- }
- });
- });
- }
- });
- }
- }
-}
-
-fn note_hover_ui(
- ui: &mut egui::Ui,
- label: &str,
- ctx: &mut AppContext,
- invoice_notes: &HashMap<String, [u8; 32]>,
-) -> Option<notedeck::NoteAction> {
- let zap_req_id = invoice_notes.get(label)?;
-
- let Ok(txn) = nostrdb::Transaction::new(ctx.ndb) else {
- return None;
- };
-
- let Ok(zapreq_note) = ctx.ndb.get_note_by_id(&txn, zap_req_id) else {
- return None;
- };
-
- for tag in zapreq_note.tags() {
- let Some("e") = tag.get_str(0) else {
- continue;
- };
-
- let Some(target_id) = tag.get_id(1) else {
- continue;
- };
-
- let Ok(note) = ctx.ndb.get_note_by_id(&txn, target_id) else {
- return None;
- };
-
- let author = ctx
- .ndb
- .get_profile_by_pubkey(&txn, zapreq_note.pubkey())
- .ok();
-
- // TODO(jb55): make this less horrible
- let mut note_context = notedeck::NoteContext {
- ndb: ctx.ndb,
- accounts: ctx.accounts,
- img_cache: ctx.img_cache,
- note_cache: ctx.note_cache,
- zaps: ctx.zaps,
- pool: ctx.pool,
- job_pool: ctx.job_pool,
- unknown_ids: ctx.unknown_ids,
- clipboard: ctx.clipboard,
- i18n: ctx.i18n,
- global_wallet: ctx.global_wallet,
- };
-
- let mut jobs = notedeck::JobsCache::default();
- let options = notedeck_ui::NoteOptions::default();
-
- notedeck_ui::ProfilePic::from_profile_or_default(note_context.img_cache, author.as_ref())
- .ui(ui);
-
- let nostr_name = notedeck::name::get_display_name(author.as_ref());
- ui.label(format!("{} zapped you", nostr_name.name()));
-
- return notedeck_ui::NoteView::new(&mut note_context, ¬e, options, &mut jobs)
- .preview_style()
- .hide_media(true)
- .show(ui)
- .action;
- }
-
- None
-}
diff --git a/crates/notedeck_clndash/src/summary.rs b/crates/notedeck_clndash/src/summary.rs
@@ -0,0 +1,140 @@
+use crate::channels::Channels;
+use crate::event::LoadingState;
+use crate::ui;
+
+#[derive(Clone, Default)]
+pub struct Summary {
+ pub total_msat: i64,
+ pub avail_out_msat: i64,
+ pub avail_in_msat: i64,
+ pub channel_count: usize,
+ pub largest_msat: i64,
+ pub outbound_pct: f32, // fraction of total capacity
+}
+
+pub 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,
+ }
+}
+
+pub fn summary_ui(
+ ui: &mut egui::Ui,
+ last_summary: Option<&Summary>,
+ summary: &LoadingState<Summary, lnsocket::Error>,
+) {
+ match summary {
+ LoadingState::Loading => {
+ ui.label("loading summary");
+ }
+ LoadingState::Failed(err) => {
+ ui.label(format!("Failed to get summary: {err}"));
+ }
+ LoadingState::Loaded(summary) => {
+ summary_cards_ui(ui, summary, last_summary);
+ ui.add_space(8.0);
+ }
+ }
+}
+
+pub 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",
+ ui::human_sat(s.total_msat),
+ prev.map(|_| ui::delta_str(s.total_msat, old.total_msat)),
+ ),
+ (
+ "Avail out",
+ ui::human_sat(s.avail_out_msat),
+ prev.map(|_| ui::delta_str(s.avail_out_msat, old.avail_out_msat)),
+ ),
+ (
+ "Avail in",
+ ui::human_sat(s.avail_in_msat),
+ prev.map(|_| ui::delta_str(s.avail_in_msat, old.avail_in_msat)),
+ ),
+ ("# Channels", s.channel_count.to_string(), None),
+ ("Largest", ui::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);
+ });
+}
diff --git a/crates/notedeck_clndash/src/ui.rs b/crates/notedeck_clndash/src/ui.rs
@@ -0,0 +1,145 @@
+use crate::event::ConnectionState;
+use crate::event::LoadingState;
+use egui::Color32;
+use egui::Label;
+use egui::RichText;
+use egui::Widget;
+use notedeck::AppContext;
+use std::collections::HashMap;
+
+pub fn note_hover_ui(
+ ui: &mut egui::Ui,
+ label: &str,
+ ctx: &mut AppContext,
+ invoice_notes: &HashMap<String, [u8; 32]>,
+) -> Option<notedeck::NoteAction> {
+ let zap_req_id = invoice_notes.get(label)?;
+
+ let Ok(txn) = nostrdb::Transaction::new(ctx.ndb) else {
+ return None;
+ };
+
+ let Ok(zapreq_note) = ctx.ndb.get_note_by_id(&txn, zap_req_id) else {
+ return None;
+ };
+
+ for tag in zapreq_note.tags() {
+ let Some("e") = tag.get_str(0) else {
+ continue;
+ };
+
+ let Some(target_id) = tag.get_id(1) else {
+ continue;
+ };
+
+ let Ok(note) = ctx.ndb.get_note_by_id(&txn, target_id) else {
+ return None;
+ };
+
+ let author = ctx
+ .ndb
+ .get_profile_by_pubkey(&txn, zapreq_note.pubkey())
+ .ok();
+
+ // TODO(jb55): make this less horrible
+ let mut note_context = notedeck::NoteContext {
+ ndb: ctx.ndb,
+ accounts: ctx.accounts,
+ img_cache: ctx.img_cache,
+ note_cache: ctx.note_cache,
+ zaps: ctx.zaps,
+ pool: ctx.pool,
+ job_pool: ctx.job_pool,
+ unknown_ids: ctx.unknown_ids,
+ clipboard: ctx.clipboard,
+ i18n: ctx.i18n,
+ global_wallet: ctx.global_wallet,
+ };
+
+ let mut jobs = notedeck::JobsCache::default();
+ let options = notedeck_ui::NoteOptions::default();
+
+ notedeck_ui::ProfilePic::from_profile_or_default(note_context.img_cache, author.as_ref())
+ .ui(ui);
+
+ let nostr_name = notedeck::name::get_display_name(author.as_ref());
+ ui.label(format!("{} zapped you", nostr_name.name()));
+
+ return notedeck_ui::NoteView::new(&mut note_context, ¬e, options, &mut jobs)
+ .preview_style()
+ .hide_media(true)
+ .show(ui)
+ .action;
+ }
+
+ None
+}
+
+pub fn get_info_ui(ui: &mut egui::Ui, info: &LoadingState<String, lnsocket::Error>) {
+ ui.horizontal_wrapped(|ui| match info {
+ LoadingState::Loading => {}
+ LoadingState::Failed(err) => {
+ ui.label(format!("failed to fetch node info: {err}"));
+ }
+ LoadingState::Loaded(info) => {
+ ui.add(Label::new(info).wrap_mode(egui::TextWrapMode::Wrap));
+ }
+ });
+}
+
+pub fn connection_state_ui(ui: &mut egui::Ui, state: &ConnectionState) {
+ match state {
+ ConnectionState::Active => {
+ ui.add(Label::new(RichText::new("Connected").color(Color32::GREEN)));
+ }
+
+ ConnectionState::Connecting => {
+ ui.add(Label::new(
+ RichText::new("Connecting").color(Color32::YELLOW),
+ ));
+ }
+
+ ConnectionState::Dead(reason) => {
+ ui.add(Label::new(
+ RichText::new(format!("Disconnected: {reason}")).color(Color32::RED),
+ ));
+ }
+ }
+}
+
+// ---------- helper ----------
+pub 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()
+ }
+}
+
+pub fn human_verbose_sat(msat: i64) -> String {
+ if msat < 1_000 {
+ // less than 1 sat
+ format!("{msat} msat")
+ } else {
+ let sats = msat / 1_000;
+ if sats < 100_000_000 {
+ // less than 1 BTC
+ format!("{sats} sat")
+ } else {
+ let btc = sats / 100_000_000;
+ format!("{btc} BTC")
+ }
+ }
+}
+
+pub 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(),
+ }
+}
diff --git a/crates/notedeck_clndash/src/watch.rs b/crates/notedeck_clndash/src/watch.rs
@@ -1,4 +1,4 @@
-use crate::event::Invoice;
+use crate::invoice::Invoice;
use lnsocket::CallOpts;
use lnsocket::CommandoClient;
use serde::Deserialize;