use axum::extract::State; use axum::http::StatusCode; use axum::response::Html; use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; use maud::html; use crate::web::handlers::AppState; use crate::web::templates::{format_sats_as_btc, layout}; #[derive(Debug)] pub struct UsageMetrics { pub lightning_inflow_24h: u64, pub lightning_outflow_24h: u64, pub lightning_inflow_all_time: u64, pub lightning_outflow_all_time: u64, pub onchain_inflow_24h: u64, pub onchain_outflow_24h: u64, pub onchain_inflow_all_time: u64, pub onchain_outflow_all_time: u64, } /// Calculate usage metrics from payment history fn calculate_usage_metrics(payments: &[ldk_node::payment::PaymentDetails]) -> UsageMetrics { use std::time::{SystemTime, UNIX_EPOCH}; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let twenty_four_hours_ago = now.saturating_sub(24 * 60 * 60); let mut metrics = UsageMetrics { lightning_inflow_24h: 0, lightning_outflow_24h: 0, lightning_inflow_all_time: 0, lightning_outflow_all_time: 0, onchain_inflow_24h: 0, onchain_outflow_24h: 0, onchain_inflow_all_time: 0, onchain_outflow_all_time: 0, }; for payment in payments { if payment.status != PaymentStatus::Succeeded { continue; } let amount_sats = payment.amount_msat.unwrap_or(0) / 1000; let is_recent = payment.latest_update_timestamp >= twenty_four_hours_ago; match &payment.kind { PaymentKind::Bolt11 { .. } | PaymentKind::Bolt12Offer { .. } | PaymentKind::Bolt12Refund { .. } | PaymentKind::Spontaneous { .. } | PaymentKind::Bolt11Jit { .. } => match payment.direction { PaymentDirection::Inbound => { metrics.lightning_inflow_all_time += amount_sats; if is_recent { metrics.lightning_inflow_24h += amount_sats; } } PaymentDirection::Outbound => { metrics.lightning_outflow_all_time += amount_sats; if is_recent { metrics.lightning_outflow_24h += amount_sats; } } }, PaymentKind::Onchain { .. } => match payment.direction { PaymentDirection::Inbound => { metrics.onchain_inflow_all_time += amount_sats; if is_recent { metrics.onchain_inflow_24h += amount_sats; } } PaymentDirection::Outbound => { metrics.onchain_outflow_all_time += amount_sats; if is_recent { metrics.onchain_outflow_24h += amount_sats; } } }, } } metrics } pub async fn dashboard(State(state): State) -> Result, StatusCode> { let node = &state.node.inner; let _node_id = node.node_id().to_string(); let alias = node .node_alias() .map(|a| a.to_string()) .unwrap_or_else(|| "No alias set".to_string()); let listening_addresses: Vec = state .node .inner .announcement_addresses() .as_ref() .unwrap_or(&vec![]) .iter() .map(|a| a.to_string()) .collect(); let (num_peers, num_connected_peers) = node.list_peers() .iter() .fold((0, 0), |(mut peers, mut connected), p| { if p.is_connected { connected += 1; } peers += 1; (peers, connected) }); let (num_active_channels, num_inactive_channels) = node.list_channels() .iter() .fold((0, 0), |(mut active, mut inactive), c| { if c.is_usable { active += 1; } else { inactive += 1; } (active, inactive) }); let balances = node.list_balances(); // Calculate payment metrics for dashboard let all_payments = node.list_payments_with_filter(|_| true); let metrics = calculate_usage_metrics(&all_payments); let content = html! { h2 style="text-align: center; margin-bottom: 3rem;" { "Dashboard" } // Balance Summary as metric cards div class="card" { h2 { "Balance Summary" } div class="metrics-container" { div class="metric-card" { div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) } div class="metric-label" { "Lightning Balance" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) } div class="metric-label" { "On-chain Balance" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } div class="metric-label" { "Spendable Balance" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats + balances.total_onchain_balance_sats)) } div class="metric-label" { "Combined Total" } } } } // Node Information - new layout based on Figma design section class="node-info-section" { div class="node-info-main-container" { // Left side - Node avatar and info div class="node-info-left" { div class="node-avatar" { img src="/static/images/nut.png" alt="Node Avatar" class="avatar-image"; } div class="node-details" { h2 class="node-name" { (alias.clone()) } p class="node-address" { "Listening Address: " (listening_addresses.first().unwrap_or(&"127.0.0.1:8090".to_string())) } } } // Middle - Gray container with spinning globe animation div class="node-content-box" { div class="globe-container" { svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" { defs { symbol id="icon-world" viewBox="0 0 216 100" { title { "world" } g fill-rule="nonzero" { path d="M48 94l-3-4-2-14c0-3-1-5-3-8-4-5-6-9-4-11l1-4 1-3c2-1 9 0 11 1l3 2 2 3 1 2 8 2c1 1 2 2 0 7-1 5-2 7-4 7l-2 3-2 4-2 3-2 1c-2 2-2 9 0 10v1l-3-2zM188 90l3-2h1l-4 2zM176 87h2l-1 1-1-1zM195 86l3-2-2 2h-1zM175 83l-1-2-2-1-6 1c-5 1-5 1-5-2l1-4 2-2 4-3c5-4 9-5 9-3 0 3 3 3 4 1s1-2 1 0l3 4c2 4 1 6-2 10-4 3-7 4-8 1zM100 80c-2-4-4-11-3-14l-1-6c-1-1-2-3-1-4 0-2-4-3-9-3-4 0-5 0-7-3-1-2-2-4-1-7l3-6 3-3c1-2 10-4 11-2l6 3 5-1c3 1 4 0 5-1s-1-2-2-2l-4-1c0-1 3-3 6-2 3 0 3 0 2-2-2-2-6-2-7 0l-2 2-1 2-3-2-3-3c-1 0-1 1 1 2l1 2-2-1c-4-3-6-2-8 1-2 2-4 3-5 1-1-1 0-4 2-4l2-2 1-2 3-2 3-2 2 1c3 0 7-3 5-4l-1-3h-1l-1 3-2 2h-1l-2-1c-2-1-2-1 1-4 5-4 6-4 11-3 4 1 4 1 2 2v1l3-1 6-1c5 0 6-1 5-2l2 1c1 2 2 2 2 1-2-4 12-7 14-4l11 1 29 3 1 2-3 3c-2 0-2 0-1 1l1 3h-2c-1-1-2-3-1-4h-4l-6 2c-1 1-1 1 2 2 3 2 4 6 1 8v3c1 3 0 3-3 0s-4-1-2 3c3 4 3 7-2 8-5 2-4 1-2 5 2 3 0 5-3 4l-2-1-2-2-1-1-1-1-2-2c-1-2-1-2-4 0-2 1-3 4-3 5-1 3-1 3-3 1l-2-4c0-2-1-3-2-3l-1-1-4-2-6-1-4-2c-1 1 3 4 5 4h2c1 1 0 2-1 4-3 2-7 4-8 3l-7-10 5 10c2 2 3 3 5 2 3 0 2 1-2 7-4 4-4 5-4 8 1 3 1 4-1 6l-2 3c0 2-6 9-8 9l-3-2zm22-51l-2-3-1-1v-1c-2 0-2 2-1 4 2 3 4 4 4 1z" {} path d="M117 75c-1-2 0-6 2-7h2l-2 5c0 2-1 3-2 1zM186 64h-3c-2 0-6-3-5-5 1-1 6 1 7 3l2 3-2-1zM160 62h2c1 1 0 1-1 1l-1-1zM154 57l-1-2c2 2 3 1 2-2l-2-3 2 2 1 4 1 3v2l-3-4zM161 59c-1-1-1-2 1-4 3-3 4-3 4 0 0 4-2 6-5 4zM167 59l1-1 1 1-1 1-1-1zM176 59l1-1v2l-1-1zM141 52l1-1v2l-1-1zM170 52l1-1v2l-1-1zM32 50c-1-2-4-3-6-4-4-1-5-3-7-6l-3-5-2-2c-1-3-1-6 2-9 1-1 2-3 1-5 0-4-3-5-8-4H4l2-2 1-1 1-1 2-1c1-2 7-2 23-1 12 1 12 1 12-1h1c1 1 2 2 3 1l1 1-3 1c-2 0-8 4-8 5l2 1 2 3 4-3c3-4 4-4 5-3l3 1 1 2 1 2c3 0-1 2-4 2-2 0-2 0-2 2 1 1 0 2-2 2-4 1-12 9-12 12 0 2 0 2-1 1 0-2-2-3-6-2-3 0-4 1-4 3-2 4 0 6 3 4 3-1 3-1 2 1s-1 2 1 2l1 2 1 3 1 1-3-2zm8-24l1-1c0-1-4-3-5-2l1 1v2c-1 1-1 1 0 0h3zM167 47v-3l1 2c1 2 0 3-1 1z" {} path d="M41 43h2l-1 1-1-1zM37 42v-1l2 1h-2zM16 38l1-1v2l-1-1zM172 32l2-3h1c1 2 0 4-3 4v-1zM173 26h2l-1 1-1-1zM56 22h2l-2 1v-1zM87 19l1-2 1 3-1 1-1-2zM85 19l1-1v1l-1 1v-1zM64 12l1-3c2 0-1-4-3-4s-2 0 0-1V3l-6 2c-3 1-3 1-2-1 2-1 4-2 15-2h14c0 2-6 7-10 9l-5 2-2 1-2-2zM53 12l1-1c2 0-1-3-3-3-2-1-1-1 1-1l4 2c2 1 2 1 1 3-2 1-4 2-4 0zM80 12l1-1 1 1-1 1-1-1zM36 8h-2V7c1-1 7 0 7 1h-5zM116 7l1-1v1l-1 1V7zM50 5h2l-1 1-1-1zM97 5l2-1c0-1 1-1 0 0l-2 1z" {} } } symbol id="icon-repeated-world" viewBox="0 0 432 100" { use href="#icon-world" x="0" {} use href="#icon-world" x="189" {} } } } span class="world" { span class="images" { svg { use href="#icon-repeated-world" {} } } } } } } // Right side - Connections metrics aside class="node-metrics" { div class="card" { h3 { "Connections" } div class="metrics-container" { div class="metric-card" { div class="metric-value" { (format!("{}/{}", num_connected_peers, num_peers)) } div class="metric-label" { "Connected Peers" } } div class="metric-card" { div class="metric-value" { (format!("{}/{}", num_active_channels, num_active_channels + num_inactive_channels)) } div class="metric-label" { "Active Channels" } } } } } } // Lightning Network Activity as metric cards div class="card" { h2 { "Lightning Network Activity" } div class="metrics-container" { div class="metric-card" { div class="metric-value" { (format_sats_as_btc(metrics.lightning_inflow_24h)) } div class="metric-label" { "24h LN Inflow" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(metrics.lightning_outflow_24h)) } div class="metric-label" { "24h LN Outflow" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(metrics.lightning_inflow_all_time)) } div class="metric-label" { "All-time LN Inflow" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(metrics.lightning_outflow_all_time)) } div class="metric-label" { "All-time LN Outflow" } } } } // On-chain Activity as metric cards div class="card" { h2 { "On-chain Activity" } div class="metrics-container" { div class="metric-card" { div class="metric-value" { (format_sats_as_btc(metrics.onchain_inflow_24h)) } div class="metric-label" { "24h On-chain Inflow" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(metrics.onchain_outflow_24h)) } div class="metric-label" { "24h On-chain Outflow" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(metrics.onchain_inflow_all_time)) } div class="metric-label" { "All-time On-chain Inflow" } } div class="metric-card" { div class="metric-value" { (format_sats_as_btc(metrics.onchain_outflow_all_time)) } div class="metric-label" { "All-time On-chain Outflow" } } } } }; Ok(Html(layout("Dashboard", content).into_string())) }