From 2cf5a52fb6637884f2290c314ca9d0e98d7c87b7 Mon Sep 17 00:00:00 2001 From: Santiago Date: Wed, 1 Jul 2026 07:13:20 -0300 Subject: [PATCH 1/2] feat(leios-tui): add terminal dashboard for the Leios testnet --- Cargo.toml | 1 + examples/leios-tui/Cargo.toml | 21 + examples/leios-tui/src/dashboard.rs | 698 ++++++++++++++++++++++++++++ examples/leios-tui/src/logbuf.rs | 83 ++++ examples/leios-tui/src/main.rs | 171 +++++++ examples/leios-tui/src/ui.rs | 372 +++++++++++++++ 6 files changed, 1346 insertions(+) create mode 100644 examples/leios-tui/Cargo.toml create mode 100644 examples/leios-tui/src/dashboard.rs create mode 100644 examples/leios-tui/src/logbuf.rs create mode 100644 examples/leios-tui/src/main.rs create mode 100644 examples/leios-tui/src/ui.rs diff --git a/Cargo.toml b/Cargo.toml index fac0fb79..d7bd34ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "examples/p2p-responder", "examples/p2p-initiator", "examples/leios-testnet", + "examples/leios-tui", ] [workspace.dependencies] diff --git a/examples/leios-tui/Cargo.toml b/examples/leios-tui/Cargo.toml new file mode 100644 index 00000000..8bc612c5 --- /dev/null +++ b/examples/leios-tui/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "leios-tui" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +publish = false + +[package.metadata.release] +release = false + +[dependencies] +pallas-network2 = { path = "../../pallas-network2" } +pallas-codec = { path = "../../pallas-codec" } +hex = "0.4.3" +tokio = { version = "1.27.0", features = ["rt-multi-thread", "macros", "time"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +indexmap = "2" +futures = "0.3" diff --git a/examples/leios-tui/src/dashboard.rs b/examples/leios-tui/src/dashboard.rs new file mode 100644 index 00000000..fa30f3dd --- /dev/null +++ b/examples/leios-tui/src/dashboard.rs @@ -0,0 +1,698 @@ +//! Observable state of the initiator node, plus the pure mapping from +//! [`InitiatorEvent`]s to state mutations and outbound [`Action`]s. +//! +//! [`Dashboard::apply_event`] is deliberately free of any network handle so it +//! can be driven by a synthetic event sequence in tests (see the bottom of this +//! file) without touching a socket. + +use std::collections::{HashSet, VecDeque}; +use std::time::Instant; + +use indexmap::IndexMap; +use pallas_codec::minicbor::{Decoder, data::Type}; +use pallas_network2::{ + PeerId, + behavior::initiator::InitiatorEvent, + protocol::{ + EbId, Point, + chainsync::HeaderContent, + handshake::n2n::LEIOS_MIN_VERSION, + leiosfetch::{self, Bitmaps}, + leiosnotify, + }, +}; + +use crate::logbuf::SharedLog; + +/// Transactions requested per leios-fetch call: one 64-tx bitmap window. We page +/// across windows (see the `BlockTxs` handler) to pull a whole EB while keeping +/// each request inside the relay's per-response limit. +const MAX_TXS_PER_FETCH: usize = 64; + +/// Cap on how many EB rows are retained (newest kept, oldest dropped). +const MAX_EBS: usize = 100; + +/// A network command the loop should issue on the node's behalf. Returned by +/// [`Dashboard::apply_event`] so the dashboard itself stays network-free. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Resume chain-sync for the peer. + ContinueSync(PeerId), + /// Fetch a complete EB body. + FetchEb(PeerId, EbId), + /// Fetch a subset of an EB's transactions. + FetchEbTxs(PeerId, EbId, Bitmaps), +} + +/// The lifecycle stage of an EB as observed from the initiator. Monotonic: +/// transitions only ever advance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum EbStage { + /// The peer offered the body (`BlockOffer`). + Offered, + /// We fetched the body and learned the tx count. + BodyFetched, + /// The peer offered the transactions (`BlockTxsOffer`). + TxsOffered, + /// We fetched (some of) the transactions. + TxsFetched, +} + +/// One row of the EB table. +#[derive(Debug, Clone)] +pub struct EbRow { + pub slot: u64, + pub hash: Vec, + pub size: Option, + pub tx_total: Option, + pub tx_fetched: usize, + pub votes: usize, + pub voters: HashSet, + pub stage: EbStage, +} + +impl EbRow { + fn new(slot: u64, hash: Vec) -> Self { + Self { + slot, + hash, + size: None, + tx_total: None, + tx_fetched: 0, + votes: 0, + voters: HashSet::new(), + stage: EbStage::Offered, + } + } + + /// Advances the stage, never regressing. + fn advance(&mut self, stage: EbStage) { + if stage > self.stage { + self.stage = stage; + } + } +} + +/// Aggregate counters for the Leios overlay funnel. +#[derive(Debug, Default)] +pub struct OverlayCounters { + pub announced: u64, + pub offered: u64, + pub bodies: u64, + pub txs_offered: u64, + pub txs_ebs: u64, + pub tx_count: u64, + pub votes: u64, + pub voters: HashSet, + pub bytes: u64, +} + +/// Praos chain-sync view. +#[derive(Debug, Default)] +pub struct ChainView { + pub tip_height: Option, + pub tip_slot: Option, + pub local_height: Option, + pub local_slot: Option, + pub era: Option, + pub headers: u64, + pub rollbacks: u64, + /// Arrival instants of recent headers, for the rate readout / sparkline. + pub hdr_times: VecDeque, +} + +/// The negotiated peer, once the handshake completes. +#[derive(Debug, Clone)] +pub struct PeerView { + pub addr: String, + pub version: u64, + pub leios: bool, +} + +/// Everything the UI renders. +pub struct Dashboard { + pub started: Instant, + pub relay: String, + pub magic: u64, + pub peer: Option, + pub chain: ChainView, + pub overlay: OverlayCounters, + pub ebs: IndexMap, + pub log: SharedLog, + pub selected: usize, + pub follow: bool, +} + +impl Dashboard { + pub fn new(relay: String, magic: u64, log: SharedLog) -> Self { + Self { + started: Instant::now(), + relay, + magic, + peer: None, + chain: ChainView::default(), + overlay: OverlayCounters::default(), + ebs: IndexMap::new(), + log, + selected: 0, + follow: true, + } + } + + /// Applies an initiator event to the dashboard, returning any network actions + /// the caller should execute. Pure with respect to the network. + pub fn apply_event(&mut self, event: &InitiatorEvent) -> Vec { + let mut actions = Vec::new(); + + match event { + InitiatorEvent::PeerInitialized(pid, (version, _data)) => { + let leios = *version >= LEIOS_MIN_VERSION; + self.peer = Some(PeerView { + addr: pid.to_string(), + version: *version, + leios, + }); + tracing::info!(version = *version, leios, "peer initialized"); + } + + // --- Praos chain-sync --- + InitiatorEvent::IntersectionFound(pid, point, tip) => { + self.set_tip(tip); + self.chain.local_slot = Some(point.slot_or_default()); + actions.push(Action::ContinueSync(pid.clone())); + tracing::info!( + slot = point.slot_or_default(), + tip = tip.1, + "intersection found" + ); + } + InitiatorEvent::BlockHeaderReceived(pid, header, tip) => { + self.set_tip(tip); + self.chain.era = Some(header.variant); + if let Some((height, slot)) = header_pos(header) { + self.chain.local_height = Some(height); + self.chain.local_slot = Some(slot); + } + self.chain.headers += 1; + self.chain.hdr_times.push_back(Instant::now()); + while self.chain.hdr_times.len() > 256 { + self.chain.hdr_times.pop_front(); + } + actions.push(Action::ContinueSync(pid.clone())); + } + InitiatorEvent::RollbackReceived(pid, point, tip) => { + self.set_tip(tip); + self.chain.local_slot = Some(point.slot_or_default()); + self.chain.rollbacks += 1; + actions.push(Action::ContinueSync(pid.clone())); + tracing::warn!(slot = point.slot_or_default(), "rollback"); + } + + // --- Leios overlay: notify --- + InitiatorEvent::EbNotification(pid, notification) => { + actions.extend(self.on_notification(pid, notification)); + } + + // --- Leios overlay: fetch --- + InitiatorEvent::EbFetched(pid, eb, response) => match response { + leiosfetch::Response::Block(body) => { + let n = eb_tx_count(body.raw_bytes()); + self.overlay.bodies += 1; + self.overlay.bytes += body.raw_bytes().len() as u64; + if let Some(row) = self.ebs.get_mut(eb) { + row.tx_total = Some(n); + row.advance(EbStage::BodyFetched); + } + tracing::info!(eb = %fmt_eb(eb), bytes = body.raw_bytes().len(), txs = n, "eb body fetched"); + } + leiosfetch::Response::BlockTxs { txs } => { + let bytes: usize = txs.iter().map(|t| t.raw_bytes().len()).sum(); + self.overlay.txs_ebs += 1; + self.overlay.tx_count += txs.len() as u64; + self.overlay.bytes += bytes as u64; + + // Page across the remaining 64-tx windows until the whole EB + // is fetched. We advance from the actual fetched count so a + // short response self-corrects; an empty response stops paging. + let mut next = None; + let mut fetched = 0; + if let Some(row) = self.ebs.get_mut(eb) { + row.tx_fetched += txs.len(); + row.advance(EbStage::TxsFetched); + fetched = row.tx_fetched; + if let Some(total) = row.tx_total + && !txs.is_empty() + && row.tx_fetched < total + { + let start = row.tx_fetched; + let end = (start + MAX_TXS_PER_FETCH).min(total); + next = Some(Bitmaps::from_indices(start..end)); + } + } + if let Some(bitmaps) = next { + actions.push(Action::FetchEbTxs(pid.clone(), eb.clone(), bitmaps)); + } + tracing::info!(eb = %fmt_eb(eb), count = txs.len(), bytes, fetched, "eb txs fetched"); + } + }, + + // Block bodies / tx-submission requests are not part of this view. + InitiatorEvent::BlockBodyReceived(..) | InitiatorEvent::TxRequested(..) => {} + } + + actions + } + + fn on_notification( + &mut self, + pid: &PeerId, + notification: &leiosnotify::Notification, + ) -> Vec { + let mut actions = Vec::new(); + + match notification { + leiosnotify::Notification::BlockAnnouncement(raw) => { + self.overlay.announced += 1; + tracing::info!(bytes = raw.raw_bytes().len(), "eb announced"); + } + leiosnotify::Notification::BlockOffer(eb, size) => { + self.overlay.offered += 1; + self.upsert_eb(eb).size = Some(*size); + actions.push(Action::FetchEb(pid.clone(), eb.clone())); + tracing::info!(eb = %fmt_eb(eb), size, "eb offered → fetching body"); + } + leiosnotify::Notification::BlockTxsOffer(eb) => { + self.overlay.txs_offered += 1; + let total = self.ebs.get(eb).and_then(|r| r.tx_total); + if let Some(row) = self.ebs.get_mut(eb) { + row.advance(EbStage::TxsOffered); + } + match total { + Some(n) if n > 0 => { + let want = n.min(MAX_TXS_PER_FETCH); + actions.push(Action::FetchEbTxs( + pid.clone(), + eb.clone(), + Bitmaps::all(want), + )); + tracing::info!(eb = %fmt_eb(eb), want, total = n, "txs offered → fetching"); + } + _ => { + tracing::info!(eb = %fmt_eb(eb), "txs offered (body not yet fetched)"); + } + } + } + leiosnotify::Notification::Votes(votes) => { + for vote in votes { + if let Some((eb_hash, voter)) = vote_meta(vote.raw_bytes()) { + self.overlay.votes += 1; + if let Some(v) = voter { + self.overlay.voters.insert(v); + } + if let Some(row) = self.ebs.values_mut().find(|r| r.hash == eb_hash) { + row.votes += 1; + if let Some(v) = voter { + row.voters.insert(v); + } + } + } + } + tracing::info!(count = votes.len(), "votes received"); + } + } + + actions + } + + /// Inserts (or returns) the row for an EB, trimming the oldest rows past the + /// retention cap. + fn upsert_eb(&mut self, eb: &EbId) -> &mut EbRow { + if !self.ebs.contains_key(eb) { + let (slot, hash) = match eb { + Point::Specific(slot, hash) => (*slot, hash.clone()), + Point::Origin => (0, Vec::new()), + }; + self.ebs.insert(eb.clone(), EbRow::new(slot, hash)); + while self.ebs.len() > MAX_EBS { + self.ebs.shift_remove_index(0); + } + } + self.ebs.get_mut(eb).expect("just inserted") + } + + fn set_tip(&mut self, tip: &pallas_network2::protocol::chainsync::Tip) { + self.chain.tip_height = Some(tip.1); + self.chain.tip_slot = Some(tip.0.slot_or_default()); + } + + /// Handles a key event, returning `true` if the app should quit. + pub fn handle_input(&mut self, ev: crossterm::event::Event) -> bool { + use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; + + let Event::Key(key) = ev else { return false }; + if key.kind != KeyEventKind::Press { + return false; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return true, + KeyCode::Char('c') => { + if let Ok(mut buf) = self.log.lock() { + buf.clear(); + } + } + KeyCode::Char('f') => self.follow = !self.follow, + KeyCode::Up => { + self.follow = false; + self.selected = self.selected.saturating_sub(1); + } + KeyCode::Down => { + self.follow = false; + self.selected = self.selected.saturating_add(1); + } + _ => {} + } + + false + } +} + +/// Formats an EB reference (`[slot, hash]`) for logging. +pub fn fmt_eb(eb: &Point) -> String { + match eb { + Point::Origin => "origin".to_string(), + Point::Specific(slot, hash) => format!("{slot}@{}", hex::encode(hash)), + } +} + +/// Counts the transactions in an EB body, which is a `{ tx_hash => size }` CBOR +/// map — the number of entries is the transaction count. +pub fn eb_tx_count(body: &[u8]) -> usize { + let mut d = Decoder::new(body); + match d.map() { + Ok(Some(n)) => n as usize, + Ok(None) => { + let mut n = 0; + while !matches!(d.datatype(), Ok(Type::Break)) { + if d.skip().is_err() || d.skip().is_err() { + break; + } + n += 1; + } + n + } + Err(_) => 0, + } +} + +/// Decodes `(eb_hash, voter_id)` from a vote `[slot, eb_hash, voter_id, sig]`. +/// +/// `voter_id` is a `uint` per the blueprint CDDL; it is returned as `None` if it +/// is absent or not an integer, so the vote is still attributed to its EB even +/// when the voter cannot be identified. +fn vote_meta(raw: &[u8]) -> Option<(Vec, Option)> { + let mut d = Decoder::new(raw); + d.array().ok()?; + let _slot = d.u64().ok()?; + let eb_hash = d.bytes().ok()?.to_vec(); + let voter = d.u64().ok(); + Some((eb_hash, voter)) +} + +/// Decodes `(block_number, slot)` from a non-Byron chain-sync header +/// (`header = [header_body, sig]`, `header_body = [block_no, slot, ...]`). +fn header_pos(header: &HeaderContent) -> Option<(u64, u64)> { + if header.variant == 0 { + return None; + } + let mut d = Decoder::new(&header.cbor); + d.array().ok()?; // [header_body, body_signature] + d.array().ok()?; // header_body = [block_no, slot, ...] + let block_no = d.u64().ok()?; + let slot = d.u64().ok()?; + Some((block_no, slot)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pallas_codec::minicbor::Encoder; + use pallas_codec::utils::AnyCbor; + + fn eb(slot: u64, hash: u8) -> EbId { + Point::Specific(slot, vec![hash; 32]) + } + + /// Builds an EB body map with `n` `{hash => size}` entries. + fn body(n: usize) -> AnyCbor { + let mut buf = Vec::new(); + let mut e = Encoder::new(&mut buf); + e.map(n as u64).unwrap(); + for i in 0..n { + e.bytes(&[i as u8; 32]).unwrap().u32(100).unwrap(); + } + AnyCbor::from_raw_bytes(buf) + } + + /// Builds a vote `[slot, eb_hash, voter_id, sig]`. + fn vote(eb_hash: u8, voter: u16) -> AnyCbor { + let mut buf = Vec::new(); + Encoder::new(&mut buf) + .array(4) + .unwrap() + .u64(1) + .unwrap() + .bytes(&[eb_hash; 32]) + .unwrap() + .u16(voter) + .unwrap() + .bytes(&[0xEE; 48]) + .unwrap(); + AnyCbor::from_raw_bytes(buf) + } + + fn dash() -> Dashboard { + Dashboard::new("relay:3001".into(), 164, crate::logbuf::new_log()) + } + + fn pid() -> PeerId { + PeerId { + host: "relay".into(), + port: 3001, + } + } + + #[test] + fn eb_lifecycle_advances_through_all_stages() { + let mut d = dash(); + let pid = pid(); + let id = eb(7, 0xAB); + + // Offer → fetch body command, row at Offered. + let actions = d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockOffer(id.clone(), 4096), + )); + assert_eq!(actions, vec![Action::FetchEb(pid.clone(), id.clone())]); + assert_eq!(d.ebs[&id].stage, EbStage::Offered); + assert_eq!(d.ebs[&id].size, Some(4096)); + + // Body fetched → tx_total learned, stage BodyFetched. + let actions = d.apply_event(&InitiatorEvent::EbFetched( + pid.clone(), + id.clone(), + leiosfetch::Response::Block(body(12)), + )); + assert!(actions.is_empty()); + assert_eq!(d.ebs[&id].tx_total, Some(12)); + assert_eq!(d.ebs[&id].stage, EbStage::BodyFetched); + + // Txs offered → fetch sized from the known count. + let actions = d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockTxsOffer(id.clone()), + )); + assert_eq!( + actions, + vec![Action::FetchEbTxs( + pid.clone(), + id.clone(), + Bitmaps::all(12) + )] + ); + assert_eq!(d.ebs[&id].stage, EbStage::TxsOffered); + + // Txs fetched → counts recorded, stage TxsFetched. + let txs = vec![AnyCbor::from_raw_bytes(vec![1]); 12]; + let actions = d.apply_event(&InitiatorEvent::EbFetched( + pid.clone(), + id.clone(), + leiosfetch::Response::BlockTxs { txs }, + )); + assert!(actions.is_empty()); + assert_eq!(d.ebs[&id].tx_fetched, 12); + assert_eq!(d.ebs[&id].stage, EbStage::TxsFetched); + + // Votes attributed back to the EB by hash. + d.apply_event(&InitiatorEvent::EbNotification( + pid, + leiosnotify::Notification::Votes(vec![vote(0xAB, 1), vote(0xAB, 2), vote(0xAB, 2)]), + )); + assert_eq!(d.ebs[&id].votes, 3); + assert_eq!(d.ebs[&id].voters.len(), 2); + + // Overlay counters reflect the whole flow. + assert_eq!(d.overlay.offered, 1); + assert_eq!(d.overlay.bodies, 1); + assert_eq!(d.overlay.txs_offered, 1); + assert_eq!(d.overlay.txs_ebs, 1); + assert_eq!(d.overlay.tx_count, 12); + assert_eq!(d.overlay.votes, 3); + assert_eq!(d.overlay.voters.len(), 2); + } + + #[test] + fn txs_offer_without_body_does_not_fetch() { + let mut d = dash(); + let pid = pid(); + let id = eb(7, 0xAB); + + d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockOffer(id.clone(), 1), + )); + // Skip the body fetch; a txs offer now must not produce a fetch action. + let actions = d.apply_event(&InitiatorEvent::EbNotification( + pid, + leiosnotify::Notification::BlockTxsOffer(id.clone()), + )); + assert!(actions.is_empty()); + assert_eq!(d.ebs[&id].stage, EbStage::TxsOffered); + } + + #[test] + fn stage_never_regresses() { + let mut d = dash(); + let pid = pid(); + let id = eb(7, 0xAB); + + d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockOffer(id.clone(), 1), + )); + d.apply_event(&InitiatorEvent::EbFetched( + pid.clone(), + id.clone(), + leiosfetch::Response::Block(body(4)), + )); + // A late, duplicate offer must not pull the row back to Offered. + d.apply_event(&InitiatorEvent::EbNotification( + pid, + leiosnotify::Notification::BlockOffer(id.clone(), 1), + )); + assert_eq!(d.ebs[&id].stage, EbStage::BodyFetched); + } + + /// `n` dummy transactions. + fn txs(n: usize) -> Vec { + vec![AnyCbor::from_raw_bytes(vec![1]); n] + } + + #[test] + fn fetches_all_tx_windows_by_paging() { + let mut d = dash(); + let pid = pid(); + let id = eb(7, 0xAB); + + // Offer + body so the tx count (150 → 3 windows) is known. + d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockOffer(id.clone(), 4096), + )); + d.apply_event(&InitiatorEvent::EbFetched( + pid.clone(), + id.clone(), + leiosfetch::Response::Block(body(150)), + )); + + // The txs offer fetches the first window (txs 0..64). + let actions = d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockTxsOffer(id.clone()), + )); + assert_eq!( + actions, + vec![Action::FetchEbTxs( + pid.clone(), + id.clone(), + Bitmaps::all(64) + )] + ); + + // Each full response pages into the next window… + let actions = d.apply_event(&InitiatorEvent::EbFetched( + pid.clone(), + id.clone(), + leiosfetch::Response::BlockTxs { txs: txs(64) }, + )); + assert_eq!( + actions, + vec![Action::FetchEbTxs( + pid.clone(), + id.clone(), + Bitmaps::from_indices(64..128) + )] + ); + + let actions = d.apply_event(&InitiatorEvent::EbFetched( + pid.clone(), + id.clone(), + leiosfetch::Response::BlockTxs { txs: txs(64) }, + )); + assert_eq!( + actions, + vec![Action::FetchEbTxs( + pid.clone(), + id.clone(), + Bitmaps::from_indices(128..150) + )] + ); + + // …until the final partial window completes the EB — no further requests. + let actions = d.apply_event(&InitiatorEvent::EbFetched( + pid, + id.clone(), + leiosfetch::Response::BlockTxs { txs: txs(22) }, + )); + assert!(actions.is_empty()); + + assert_eq!(d.ebs[&id].tx_fetched, 150); + assert_eq!(d.ebs[&id].stage, EbStage::TxsFetched); + assert_eq!(d.overlay.tx_count, 150); + } + + #[test] + fn empty_txs_response_stops_paging() { + let mut d = dash(); + let pid = pid(); + let id = eb(7, 0xAB); + + d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockOffer(id.clone(), 1), + )); + d.apply_event(&InitiatorEvent::EbFetched( + pid.clone(), + id.clone(), + leiosfetch::Response::Block(body(150)), + )); + // A peer that yields nothing must not spin us into endless re-requests. + let actions = d.apply_event(&InitiatorEvent::EbFetched( + pid, + id.clone(), + leiosfetch::Response::BlockTxs { txs: txs(0) }, + )); + assert!(actions.is_empty()); + } +} diff --git a/examples/leios-tui/src/logbuf.rs b/examples/leios-tui/src/logbuf.rs new file mode 100644 index 00000000..619b757d --- /dev/null +++ b/examples/leios-tui/src/logbuf.rs @@ -0,0 +1,83 @@ +//! A `tracing` layer that captures formatted log lines into a shared ring buffer +//! so they can be rendered inside the TUI's Log panel instead of being written to +//! stdout (which would corrupt the alternate screen). + +use std::collections::VecDeque; +use std::fmt::Write as _; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; + +use tracing::field::{Field, Visit}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::layer::{Context, Layer}; + +/// Maximum number of log lines retained in the ring buffer. +const LOG_CAP: usize = 500; + +/// A shared, bounded buffer of formatted log lines (newest at the back). +pub type SharedLog = Arc>>; + +/// Creates an empty shared log buffer. +pub fn new_log() -> SharedLog { + Arc::new(Mutex::new(VecDeque::with_capacity(LOG_CAP))) +} + +/// Process start, used to stamp each log line with an uptime offset (avoids a +/// wall-clock dependency). +static START: OnceLock = OnceLock::new(); + +fn uptime() -> String { + let s = START.get_or_init(Instant::now).elapsed().as_secs(); + format!("{:02}:{:02}:{:02}", s / 3600, (s % 3600) / 60, s % 60) +} + +/// A `tracing` layer that appends each event to a [`SharedLog`]. +pub struct LogLayer { + buf: SharedLog, +} + +impl LogLayer { + pub fn new(buf: SharedLog) -> Self { + Self { buf } + } +} + +impl Layer for LogLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let mut visitor = MsgVisitor::default(); + event.record(&mut visitor); + + let meta = event.metadata(); + let line = format!( + "{} {:>5} {}{}", + uptime(), + meta.level(), + visitor.msg, + visitor.fields + ); + + if let Ok(mut buf) = self.buf.lock() { + buf.push_back(line); + while buf.len() > LOG_CAP { + buf.pop_front(); + } + } + } +} + +/// Collects an event's `message` plus any `key=value` fields into strings. +#[derive(Default)] +struct MsgVisitor { + msg: String, + fields: String, +} + +impl Visit for MsgVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.msg = format!("{value:?}"); + } else { + let _ = write!(self.fields, " {}={:?}", field.name(), value); + } + } +} diff --git a/examples/leios-tui/src/main.rs b/examples/leios-tui/src/main.rs new file mode 100644 index 00000000..44cc35ec --- /dev/null +++ b/examples/leios-tui/src/main.rs @@ -0,0 +1,171 @@ +//! A terminal dashboard for a Leios initiator node observing the chain. +//! +//! Connects to the Cardano **Leios** ("Musashi Dojo") testnet, negotiates the +//! v15 (Leios) handshake, follows Praos chain-sync, and fetches Endorser Blocks +//! over leios-notify / leios-fetch — the same flow as the `leios-testnet` +//! example, but rendered as a live TUI instead of log lines. +//! +//! The screen shows the Praos chain and the Leios overlay side by side, a table +//! of recent EBs advancing through their lifecycle (offer → body → txs → votes), +//! and a panel of the most recent log lines. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p leios-tui +//! ``` +//! +//! Keys: `q` quit · `↑`/`↓` scroll EBs · `f` toggle follow-tip · `c` clear log. + +mod dashboard; +mod logbuf; +mod ui; + +use std::time::Duration; + +use crossterm::event::EventStream; +use futures::StreamExt; +use pallas_network2::{ + Manager, + behavior::{ + AnyMessage, + initiator::{ + Config as HandshakeConfig, HandshakeBehavior, InitiatorBehavior, InitiatorCommand, + InitiatorEvent, + }, + }, + interface::TcpInterface, + protocol::{Point, handshake::n2n::VersionTable}, +}; +use ratatui::{DefaultTerminal, widgets::TableState}; +use tokio::{select, time::Interval}; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +use dashboard::{Action, Dashboard}; + +/// Public bootstrap relay for the Leios "Musashi Dojo" testnet. +const LEIOS_RELAY: &str = "leios-node.play.dev.cardano.org:3001"; + +/// Network magic for the Leios testnet. +const LEIOS_TESTNET_MAGIC: u64 = 164; + +/// Chain-sync intersection point, so we follow near the tip instead of replaying +/// from origin. The testnet resets periodically; replace with a current point if +/// the intersection is not found. +const INTERSECT_SLOT: u64 = 2812236; +const INTERSECT_HASH: &str = "9d8a43aa5ddfa5e2e379ad14b38c3edf98cb6898ed480726fec9da9b68aa3d0e"; + +struct LeiosNode { + network: Manager, InitiatorBehavior, AnyMessage>, + housekeeping_interval: Interval, + render_interval: Interval, + input: EventStream, + dashboard: Dashboard, + table_state: TableState, +} + +impl LeiosNode { + fn new(dashboard: Dashboard) -> Self { + let interface = TcpInterface::new(); + + // Propose v11..=v15 with the testnet magic so the peer can negotiate v15 + // and enable the Leios mini-protocols. + let behavior = InitiatorBehavior { + handshake: HandshakeBehavior::new(HandshakeConfig { + supported_version: VersionTable::v11_and_above_with_query( + LEIOS_TESTNET_MAGIC, + false, + ), + }), + ..Default::default() + }; + + Self { + network: Manager::new(interface, behavior), + housekeeping_interval: tokio::time::interval(Duration::from_secs(3)), + render_interval: tokio::time::interval(Duration::from_millis(200)), + input: EventStream::new(), + dashboard, + table_state: TableState::default(), + } + } + + /// Folds an event into the dashboard and issues any resulting commands. + fn handle_event(&mut self, event: InitiatorEvent) { + for action in self.dashboard.apply_event(&event) { + match action { + Action::ContinueSync(pid) => { + self.network.execute(InitiatorCommand::ContinueSync(pid)) + } + Action::FetchEb(pid, eb) => { + self.network.execute(InitiatorCommand::FetchEb(pid, eb)) + } + Action::FetchEbTxs(pid, eb, bitmaps) => self + .network + .execute(InitiatorCommand::FetchEbTxs(pid, eb, bitmaps)), + } + } + } + + async fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> { + terminal.draw(|f| ui::draw(f, &self.dashboard, &mut self.table_state))?; + + loop { + select! { + _ = self.housekeeping_interval.tick() => { + self.network.execute(InitiatorCommand::Housekeeping); + } + _ = self.render_interval.tick() => { + terminal.draw(|f| ui::draw(f, &self.dashboard, &mut self.table_state))?; + } + evt = self.network.poll_next() => { + if let Some(evt) = evt { + self.handle_event(evt); + } + } + ev = self.input.next() => { + if let Some(Ok(ev)) = ev + && self.dashboard.handle_input(ev) + { + return Ok(()); + } + } + } + } + } +} + +#[tokio::main] +async fn main() -> std::io::Result<()> { + let log = logbuf::new_log(); + tracing_subscriber::registry() + .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with(logbuf::LogLayer::new(log.clone())) + .init(); + + let dashboard = Dashboard::new(LEIOS_RELAY.to_string(), LEIOS_TESTNET_MAGIC, log); + let mut node = LeiosNode::new(dashboard); + + let peer = LEIOS_RELAY + .parse() + .expect("LEIOS_RELAY should be a valid host:port"); + + tracing::info!( + relay = LEIOS_RELAY, + magic = LEIOS_TESTNET_MAGIC, + "connecting to Leios testnet" + ); + + node.network.execute(InitiatorCommand::IncludePeer(peer)); + let intersect = Point::Specific( + INTERSECT_SLOT, + hex::decode(INTERSECT_HASH).expect("INTERSECT_HASH should be valid hex"), + ); + node.network + .execute(InitiatorCommand::StartSync(vec![intersect])); + + let mut terminal = ratatui::init(); + let result = node.run(&mut terminal).await; + ratatui::restore(); + result +} diff --git a/examples/leios-tui/src/ui.rs b/examples/leios-tui/src/ui.rs new file mode 100644 index 00000000..6a568bf6 --- /dev/null +++ b/examples/leios-tui/src/ui.rs @@ -0,0 +1,372 @@ +//! Renders the [`Dashboard`] as a ratatui screen. + +use std::time::{Duration, Instant}; + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, +}; + +use crate::dashboard::{ChainView, Dashboard, EbRow, EbStage}; + +/// Draws the whole dashboard for the current frame. +pub fn draw(f: &mut Frame, d: &Dashboard, table_state: &mut TableState) { + let rows = Layout::vertical([ + Constraint::Length(3), // header + Constraint::Length(8), // praos + overlay + Constraint::Min(6), // eb table + Constraint::Length(8), // log + Constraint::Length(1), // footer + ]) + .split(f.area()); + + render_header(f, d, rows[0]); + + let mid = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]); + render_praos(f, d, mid[0]); + render_overlay(f, d, mid[1]); + + render_ebs(f, d, table_state, rows[2]); + render_log(f, d, rows[3]); + render_footer(f, d, rows[4]); +} + +fn render_header(f: &mut Frame, d: &Dashboard, area: Rect) { + let (dot, status) = if let Some(p) = &d.peer { + let leios = if p.leios { "Leios ✓" } else { "Leios ✗" }; + ( + Span::styled("●", Style::default().fg(Color::Green)), + format!("magic {} N2N v{} {}", d.magic, p.version, leios), + ) + } else { + ( + Span::styled("○", Style::default().fg(Color::DarkGray)), + format!("magic {} connecting…", d.magic), + ) + }; + + let addr = d + .peer + .as_ref() + .map(|p| p.addr.clone()) + .unwrap_or_else(|| d.relay.clone()); + + let line = Line::from(vec![ + Span::raw(format!("peer {addr} ")), + dot, + Span::raw(format!( + " {status} uptime {}", + fmt_dur(d.started.elapsed()) + )), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Leios testnet · initiator "); + f.render_widget(Paragraph::new(line).block(block), area); +} + +fn render_praos(f: &mut Frame, d: &Dashboard, area: Rect) { + let c = &d.chain; + let lag = match (c.tip_height, c.local_height) { + (Some(t), Some(l)) => format!("{} blocks", t.saturating_sub(l)), + _ => "—".to_string(), + }; + let rate = hdr_rate(c, Duration::from_secs(30)); + let bars = spark(&hdr_buckets(c, 16, Duration::from_secs(60))); + let era = c.era.map(era_name).unwrap_or_else(|| "—".to_string()); + + let lines = vec![ + Line::from(format!("tip {}", fmt_pt(c.tip_height, c.tip_slot))), + Line::from(format!("local {}", fmt_pt(c.local_height, c.local_slot))), + Line::from(format!("lag {lag}")), + Line::from(format!("era {era}")), + Line::from(format!("hdr/s {rate:.1} {bars}")), + Line::from(format!( + " headers {} rollbacks {}", + c.headers, c.rollbacks + )), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .title(" Praos chain "); + f.render_widget(Paragraph::new(lines).block(block), area); +} + +fn render_overlay(f: &mut Frame, d: &Dashboard, area: Rect) { + let o = &d.overlay; + let lines = vec![ + Line::from(format!("announced {}", group(o.announced))), + Line::from(format!( + "offered {} bodies {}", + group(o.offered), + group(o.bodies) + )), + Line::from(format!( + "txs offered {} txs {} / {} tx", + group(o.txs_offered), + group(o.txs_ebs), + group(o.tx_count) + )), + Line::from(format!( + "votes {} voters {}", + group(o.votes), + group(o.voters.len() as u64) + )), + Line::from(format!("fetched {}", human_bytes(o.bytes))), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .title(" Leios overlay "); + f.render_widget(Paragraph::new(lines).block(block), area); +} + +fn render_ebs(f: &mut Frame, d: &Dashboard, table_state: &mut TableState, area: Rect) { + let rows: Vec = d + .ebs + .values() + .rev() + .map(|r| { + let hash = if r.hash.len() >= 4 { + format!("{}…", hex::encode(&r.hash[..4])) + } else { + hex::encode(&r.hash) + }; + let size = r + .size + .map(|s| human_bytes(s as u64)) + .unwrap_or_else(|| "—".to_string()); + let txs = match r.tx_total { + Some(n) => format!("{}/{}", r.tx_fetched, n), + None => "—".to_string(), + }; + let (votes, vstyle) = if r.votes > 0 { + (format!("{} ●", r.votes), Style::default().fg(Color::Green)) + } else { + ("—".to_string(), Style::default().fg(Color::DarkGray)) + }; + + Row::new(vec![ + Cell::from(group(r.slot)), + Cell::from(hash), + Cell::from(size), + Cell::from(txs), + Cell::from(votes).style(vstyle), + Cell::from(lifecycle(r)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(11), + Constraint::Length(11), + Constraint::Length(9), + Constraint::Length(9), + Constraint::Length(7), + Constraint::Min(22), + ]; + let header = Row::new(vec!["slot", "eb hash", "size", "txs", "votes", "lifecycle"]) + .style(Style::default().add_modifier(Modifier::BOLD)); + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Endorser Blocks ({}) ", d.ebs.len())), + ) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("▌"); + + let len = d.ebs.len(); + if len == 0 { + table_state.select(None); + } else { + let sel = if d.follow { 0 } else { d.selected.min(len - 1) }; + table_state.select(Some(sel)); + } + + f.render_stateful_widget(table, area, table_state); +} + +fn render_log(f: &mut Frame, d: &Dashboard, area: Rect) { + let height = area.height.saturating_sub(2) as usize; + let lines: Vec = match d.log.lock() { + Ok(buf) => buf + .iter() + .rev() + .take(height) + .rev() + .map(|s| Line::from(s.clone())) + .collect(), + Err(_) => Vec::new(), + }; + + let block = Block::default().borders(Borders::ALL).title(" Log "); + f.render_widget(Paragraph::new(lines).block(block), area); +} + +fn render_footer(f: &mut Frame, d: &Dashboard, area: Rect) { + let follow = if d.follow { "on" } else { "off" }; + let line = Line::from(format!( + " q quit ↑/↓ scroll f follow:{follow} c clear log " + )) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(Paragraph::new(line), area); +} + +/// Builds the lifecycle chip for an EB row: reached stages bright, pending dim, +/// with a short hint naming the next awaited step. +fn lifecycle(row: &EbRow) -> Line<'static> { + let reached = |on: bool, label: &str| { + let style = if on { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + Span::styled(label.to_string(), style) + }; + let sep = || Span::styled("▸".to_string(), Style::default().fg(Color::DarkGray)); + + let body = row.stage >= EbStage::BodyFetched; + let txs = row.stage >= EbStage::TxsFetched; + let voted = row.votes > 0; + + let mut spans = vec![ + reached(true, "offer"), + sep(), + reached(body, "body"), + sep(), + reached(txs, "txs"), + sep(), + reached(voted, "vote"), + ]; + + let hint = if !body { + " awaiting body" + } else if row.stage < EbStage::TxsOffered { + " awaiting offer" + } else if !txs { + " txs offered" + } else { + "" + }; + if !hint.is_empty() { + spans.push(Span::styled( + hint.to_string(), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )); + } + + Line::from(spans) +} + +// --------------------------------------------------------------------------- +// formatting helpers +// --------------------------------------------------------------------------- + +fn fmt_dur(d: Duration) -> String { + let s = d.as_secs(); + format!("{:02}:{:02}:{:02}", s / 3600, (s % 3600) / 60, s % 60) +} + +fn fmt_pt(height: Option, slot: Option) -> String { + match (height, slot) { + (Some(h), Some(s)) => format!("#{} · slot {}", group(h), group(s)), + (None, Some(s)) => format!("#? · slot {}", group(s)), + _ => "—".to_string(), + } +} + +/// Inserts thin spaces every three digits for readability. +fn group(n: u64) -> String { + let s = n.to_string(); + let len = s.len(); + let mut out = String::with_capacity(len + len / 3); + for (i, c) in s.chars().enumerate() { + if i > 0 && (len - i).is_multiple_of(3) { + out.push(' '); + } + out.push(c); + } + out +} + +fn human_bytes(n: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut v = n as f64; + let mut i = 0; + while v >= 1024.0 && i < UNITS.len() - 1 { + v /= 1024.0; + i += 1; + } + if i == 0 { + format!("{n} B") + } else { + format!("{v:.1} {}", UNITS[i]) + } +} + +fn era_name(variant: u8) -> String { + let name = match variant { + 0 => "Byron", + 1 => "Shelley", + 2 => "Allegra", + 3 => "Mary", + 4 => "Alonzo", + 5 => "Babbage", + 6 => "Conway", + 7 => "Dijkstra", + _ => return format!("era {variant}"), + }; + format!("{name} ({variant})") +} + +/// Header arrivals per bucket over `window`, oldest-left / newest-right. +fn hdr_buckets(chain: &ChainView, n: usize, window: Duration) -> Vec { + let now = Instant::now(); + let bucket = window.as_secs_f64() / n as f64; + let mut out = vec![0u64; n]; + for &t in &chain.hdr_times { + let age = now.saturating_duration_since(t).as_secs_f64(); + if age >= window.as_secs_f64() { + continue; + } + let idx = ((age / bucket) as usize).min(n - 1); + out[n - 1 - idx] += 1; + } + out +} + +fn hdr_rate(chain: &ChainView, window: Duration) -> f64 { + let now = Instant::now(); + let count = chain + .hdr_times + .iter() + .filter(|&&t| now.saturating_duration_since(t) < window) + .count(); + count as f64 / window.as_secs_f64() +} + +fn spark(data: &[u64]) -> String { + const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let max = data.iter().copied().max().unwrap_or(0); + if max == 0 { + return BARS[0].to_string().repeat(data.len()); + } + data.iter() + .map(|&v| { + let idx = ((v as f64 / max as f64) * (BARS.len() - 1) as f64).round() as usize; + BARS[idx.min(BARS.len() - 1)] + }) + .collect() +} From 98cc3eeae4cde438fbacc9695b7eeeabac725155 Mon Sep 17 00:00:00 2001 From: Santiago Date: Wed, 1 Jul 2026 10:13:01 -0300 Subject: [PATCH 2/2] feat(leios-tui): visualize the RB/EB overlay as aligned swim lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the dashboard as an educational view of the Leios overlay: ranking blocks and endorser blocks render as boxes in two tip-following lanes sharing one column axis, so an EB sits under the RB it belongs to and the shared column is the connection. The EB→RB association is a slot heuristic (nearest following RB) since no on-wire message links them; BlockAnnouncement decoding is left as a hook for when the devnet diffuses announcements. EB boxes show tx-download and vote bars plus a per-phase checklist; RB boxes show a derived block hash and slot. Also refreshes the chain-sync intersect point to a current tip. --- examples/leios-tui/Cargo.toml | 1 + examples/leios-tui/src/dashboard.rs | 260 ++++++++++- examples/leios-tui/src/main.rs | 24 +- examples/leios-tui/src/ui.rs | 677 ++++++++++++++++++++-------- 4 files changed, 770 insertions(+), 192 deletions(-) diff --git a/examples/leios-tui/Cargo.toml b/examples/leios-tui/Cargo.toml index 8bc612c5..45d5058a 100644 --- a/examples/leios-tui/Cargo.toml +++ b/examples/leios-tui/Cargo.toml @@ -11,6 +11,7 @@ release = false [dependencies] pallas-network2 = { path = "../../pallas-network2" } pallas-codec = { path = "../../pallas-codec" } +pallas-crypto = { path = "../../pallas-crypto" } hex = "0.4.3" tokio = { version = "1.27.0", features = ["rt-multi-thread", "macros", "time"] } tracing = "0.1.41" diff --git a/examples/leios-tui/src/dashboard.rs b/examples/leios-tui/src/dashboard.rs index fa30f3dd..e4df0990 100644 --- a/examples/leios-tui/src/dashboard.rs +++ b/examples/leios-tui/src/dashboard.rs @@ -10,6 +10,7 @@ use std::time::Instant; use indexmap::IndexMap; use pallas_codec::minicbor::{Decoder, data::Type}; +use pallas_crypto::hash::Hasher; use pallas_network2::{ PeerId, behavior::initiator::InitiatorEvent, @@ -32,6 +33,16 @@ const MAX_TXS_PER_FETCH: usize = 64; /// Cap on how many EB rows are retained (newest kept, oldest dropped). const MAX_EBS: usize = 100; +/// Cap on how many ranking-block cards are retained for the RB strip. +const MAX_RBS: usize = 16; + +/// Floor for the vote-bar denominator. Votes carry no stake weight on the wire +/// and the crate has no notion of committee size or quorum, so the bar is scaled +/// to the peak distinct-voter count observed on any EB — floored here so an EB's +/// first vote doesn't render a misleadingly full bar. (Real Leios quorum is +/// stake-weighted τ≈75%, per CIP-0164, and is not observable from this feed.) +const MIN_VOTE_SCALE: usize = 4; + /// A network command the loop should issue on the node's behalf. Returned by /// [`Dashboard::apply_event`] so the dashboard itself stays network-free. #[derive(Debug, Clone, PartialEq, Eq)] @@ -58,7 +69,7 @@ pub enum EbStage { TxsFetched, } -/// One row of the EB table. +/// One endorser block, rendered as a card under its ranking-block column. #[derive(Debug, Clone)] pub struct EbRow { pub slot: u64, @@ -69,6 +80,12 @@ pub struct EbRow { pub votes: usize, pub voters: HashSet, pub stage: EbStage, + /// `block_no` of the ranking block whose column this EB is drawn under. This + /// is a **heuristic** association (nearest RB with `slot >= eb_slot`), because + /// no on-wire message links an EB to its RB: `leios_cert` is an empty + /// placeholder and `BlockAnnouncement` is unspecified (`any`). `None` while + /// no RB has yet reached the EB's slot (drawn "pending" in the tip column). + pub column_rb: Option, } impl EbRow { @@ -82,6 +99,7 @@ impl EbRow { votes: 0, voters: HashSet::new(), stage: EbStage::Offered, + column_rb: None, } } @@ -91,6 +109,15 @@ impl EbRow { self.stage = stage; } } + + /// Fraction for this EB's vote bar: distinct voters over `scale` (the peak + /// voter count observed so far), clamped to `[0, 1]`. + pub fn vote_ratio(&self, scale: usize) -> f64 { + if scale == 0 { + return 0.0; + } + (self.voters.len() as f64 / scale as f64).min(1.0) + } } /// Aggregate counters for the Leios overlay funnel. @@ -107,6 +134,17 @@ pub struct OverlayCounters { pub bytes: u64, } +/// A retained ranking block, rendered as a box in the RB lane. `HeaderContent` +/// carries no block hash, so we derive it as the blake2b-256 of the raw header +/// CBOR (the Cardano block-hash definition) for a stable short identifier. +#[derive(Debug, Clone)] +pub struct RbCard { + pub block_no: u64, + pub slot: u64, + pub era: u8, + pub hash: Vec, +} + /// Praos chain-sync view. #[derive(Debug, Default)] pub struct ChainView { @@ -117,10 +155,22 @@ pub struct ChainView { pub era: Option, pub headers: u64, pub rollbacks: u64, + /// Recent ranking blocks (newest at the back), for the RB strip. + pub rbs: VecDeque, /// Arrival instants of recent headers, for the rate readout / sparkline. pub hdr_times: VecDeque, } +impl ChainView { + /// Appends a ranking block, dropping the oldest past the retention cap. + fn push_rb(&mut self, rb: RbCard) { + self.rbs.push_back(rb); + while self.rbs.len() > MAX_RBS { + self.rbs.pop_front(); + } + } +} + /// The negotiated peer, once the handshake completes. #[derive(Debug, Clone)] pub struct PeerView { @@ -138,6 +188,9 @@ pub struct Dashboard { pub chain: ChainView, pub overlay: OverlayCounters, pub ebs: IndexMap, + /// Peak distinct-voter count observed on any single EB, used as the + /// self-calibrating denominator for every EB's vote bar. + pub peak_voters: usize, pub log: SharedLog, pub selected: usize, pub follow: bool, @@ -153,12 +206,19 @@ impl Dashboard { chain: ChainView::default(), overlay: OverlayCounters::default(), ebs: IndexMap::new(), + peak_voters: 0, log, selected: 0, follow: true, } } + /// Denominator for the vote bars: the peak distinct-voter count seen on any + /// EB, floored by [`MIN_VOTE_SCALE`] so early bars aren't misleadingly full. + pub fn vote_scale(&self) -> usize { + self.peak_voters.max(MIN_VOTE_SCALE) + } + /// Applies an initiator event to the dashboard, returning any network actions /// the caller should execute. Pure with respect to the network. pub fn apply_event(&mut self, event: &InitiatorEvent) -> Vec { @@ -192,6 +252,13 @@ impl Dashboard { if let Some((height, slot)) = header_pos(header) { self.chain.local_height = Some(height); self.chain.local_slot = Some(slot); + self.chain.push_rb(RbCard { + block_no: height, + slot, + era: header.variant, + hash: Hasher::<256>::hash(&header.cbor).as_ref().to_vec(), + }); + self.claim_pending_ebs(height, slot); } self.chain.headers += 1; self.chain.hdr_times.push_back(Instant::now()); @@ -273,11 +340,21 @@ impl Dashboard { match notification { leiosnotify::Notification::BlockAnnouncement(raw) => { self.overlay.announced += 1; + // TODO(tier-1): the announcement is "the announcing RB header" + // (CDDL `announcement = any`), so it *may* carry the announced + // EB's hash + RB ref — the authoritative EB↔RB link. When the + // devnet diffuses announcements we can inspect the bytes and, if + // so, decode `raw` here and set the EB's `column_rb` exactly, + // replacing the slot heuristic below. Left as a hook until then. tracing::info!(bytes = raw.raw_bytes().len(), "eb announced"); } leiosnotify::Notification::BlockOffer(eb, size) => { self.overlay.offered += 1; self.upsert_eb(eb).size = Some(*size); + // Assign the EB's RB column now in case an RB at/after its slot + // was already seen (out-of-order); otherwise it stays pending + // until `claim_pending_ebs` links it on RB arrival. + self.link_eb(eb); actions.push(Action::FetchEb(pid.clone(), eb.clone())); tracing::info!(eb = %fmt_eb(eb), size, "eb offered → fetching body"); } @@ -314,6 +391,7 @@ impl Dashboard { if let Some(v) = voter { row.voters.insert(v); } + self.peak_voters = self.peak_voters.max(row.voters.len()); } } } @@ -345,6 +423,38 @@ impl Dashboard { self.chain.tip_slot = Some(tip.0.slot_or_default()); } + /// Links one EB to its nearest **following** ranking block — the retained RB + /// with the smallest `slot >= eb_slot`. Used at offer time to catch the case + /// where such an RB was already seen (out-of-order arrival). Heuristic: no + /// on-wire message ties an EB to an RB (see [`EbRow::column_rb`]). + fn link_eb(&mut self, eb: &EbId) { + let Point::Specific(slot, _) = eb else { return }; + let slot = *slot; + let block_no = self + .chain + .rbs + .iter() + .filter(|r| r.slot >= slot) + .min_by_key(|r| r.slot) + .map(|r| r.block_no); + if let Some(block_no) = block_no + && let Some(row) = self.ebs.get_mut(eb) + { + row.column_rb = Some(block_no); + } + } + + /// On a newly arrived RB, claims every still-unlinked EB at or before its + /// slot. Any earlier RB would already have claimed those EBs, so this RB is + /// their nearest following one. Heuristic (see [`EbRow::column_rb`]). + fn claim_pending_ebs(&mut self, rb_block_no: u64, rb_slot: u64) { + for row in self.ebs.values_mut() { + if row.column_rb.is_none() && row.slot <= rb_slot { + row.column_rb = Some(rb_block_no); + } + } + } + /// Handles a key event, returning `true` if the app should quit. pub fn handle_input(&mut self, ev: crossterm::event::Event) -> bool { use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; @@ -363,11 +473,13 @@ impl Dashboard { } } KeyCode::Char('f') => self.follow = !self.follow, - KeyCode::Up => { + // Selection moves through the newest-first EB list; ←/↑ toward newer, + // →/↓ toward older. Any move drops follow so the selection holds. + KeyCode::Left | KeyCode::Up => { self.follow = false; self.selected = self.selected.saturating_sub(1); } - KeyCode::Down => { + KeyCode::Right | KeyCode::Down => { self.follow = false; self.selected = self.selected.saturating_add(1); } @@ -483,6 +595,32 @@ mod tests { } } + /// Builds a non-Byron chain-sync header whose body starts `[block_no, slot]`, + /// which is all `header_pos` decodes. + fn header(block_no: u64, slot: u64) -> HeaderContent { + let mut cbor = Vec::new(); + Encoder::new(&mut cbor) + .array(2) + .unwrap() // [header_body, body_signature] + .array(2) + .unwrap() // header_body = [block_no, slot] + .u64(block_no) + .unwrap() + .u64(slot) + .unwrap() + .bytes(&[0u8; 4]) + .unwrap(); + HeaderContent { + variant: 7, // Dijkstra (non-Byron, so header_pos decodes it) + byron_prefix: None, + cbor, + } + } + + fn tip(slot: u64, height: u64) -> pallas_network2::protocol::chainsync::Tip { + pallas_network2::protocol::chainsync::Tip(Point::Specific(slot, vec![0u8; 32]), height) + } + #[test] fn eb_lifecycle_advances_through_all_stages() { let mut d = dash(); @@ -695,4 +833,120 @@ mod tests { )); assert!(actions.is_empty()); } + + #[test] + fn rb_strip_retains_recent_and_caps() { + let mut d = dash(); + let pid = pid(); + let extra = 5u64; + for i in 0..(MAX_RBS as u64 + extra) { + d.apply_event(&InitiatorEvent::BlockHeaderReceived( + pid.clone(), + header(1000 + i, 2_000_000 + i), + tip(2_000_100, 1100), + )); + } + + // Only the newest MAX_RBS are kept; the oldest `extra` are dropped. + assert_eq!(d.chain.rbs.len(), MAX_RBS); + assert_eq!(d.chain.rbs.front().unwrap().block_no, 1000 + extra); + assert_eq!( + d.chain.rbs.back().unwrap().block_no, + 1000 + MAX_RBS as u64 + extra - 1 + ); + assert_eq!(d.chain.rbs.back().unwrap().era, 7); + } + + #[test] + fn peak_voters_tracks_max_distinct_voters() { + let mut d = dash(); + let pid = pid(); + let a = eb(1, 0x11); + let b = eb(2, 0x22); + + d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockOffer(a.clone(), 1), + )); + d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockOffer(b.clone(), 1), + )); + + // EB a: 2 distinct voters (a repeat doesn't grow the set). + d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::Votes(vec![vote(0x11, 1), vote(0x11, 2), vote(0x11, 2)]), + )); + assert_eq!(d.peak_voters, 2); + + // EB b: 3 distinct voters raises the peak. + d.apply_event(&InitiatorEvent::EbNotification( + pid, + leiosnotify::Notification::Votes(vec![vote(0x22, 1), vote(0x22, 2), vote(0x22, 3)]), + )); + assert_eq!(d.peak_voters, 3); + + // Scale is floored; each EB's bar is voters / scale. + assert_eq!(d.vote_scale(), MIN_VOTE_SCALE.max(3)); + assert_eq!(d.ebs[&b].vote_ratio(d.vote_scale()), 3.0 / 4.0); + } + + #[test] + fn eb_links_to_nearest_following_rb() { + let mut d = dash(); + let pid = pid(); + let e = eb(50, 0xAA); + + // Offered before any RB → pending (no column). + d.apply_event(&InitiatorEvent::EbNotification( + pid.clone(), + leiosnotify::Notification::BlockOffer(e.clone(), 1), + )); + assert_eq!(d.ebs[&e].column_rb, None); + + // An RB *before* the EB's slot must not claim it. + d.apply_event(&InitiatorEvent::BlockHeaderReceived( + pid.clone(), + header(100, 45), + tip(200, 100), + )); + assert_eq!(d.ebs[&e].column_rb, None); + + // The first RB at/after the EB's slot claims it. + d.apply_event(&InitiatorEvent::BlockHeaderReceived( + pid.clone(), + header(101, 60), + tip(200, 101), + )); + assert_eq!(d.ebs[&e].column_rb, Some(101)); + + // A later RB does not re-home an already-linked EB. + d.apply_event(&InitiatorEvent::BlockHeaderReceived( + pid, + header(102, 70), + tip(200, 102), + )); + assert_eq!(d.ebs[&e].column_rb, Some(101)); + } + + #[test] + fn eb_offered_after_its_rb_links_immediately() { + let mut d = dash(); + let pid = pid(); + + // RB at slot 60 already present… + d.apply_event(&InitiatorEvent::BlockHeaderReceived( + pid.clone(), + header(101, 60), + tip(200, 101), + )); + // …then an EB at slot 55 arrives out of order → links to RB 101 at once. + let e = eb(55, 0xBB); + d.apply_event(&InitiatorEvent::EbNotification( + pid, + leiosnotify::Notification::BlockOffer(e.clone(), 1), + )); + assert_eq!(d.ebs[&e].column_rb, Some(101)); + } } diff --git a/examples/leios-tui/src/main.rs b/examples/leios-tui/src/main.rs index 44cc35ec..192beb05 100644 --- a/examples/leios-tui/src/main.rs +++ b/examples/leios-tui/src/main.rs @@ -5,9 +5,13 @@ //! over leios-notify / leios-fetch — the same flow as the `leios-testnet` //! example, but rendered as a live TUI instead of log lines. //! -//! The screen shows the Praos chain and the Leios overlay side by side, a table -//! of recent EBs advancing through their lifecycle (offer → body → txs → votes), -//! and a panel of the most recent log lines. +//! The screen renders ranking blocks (RBs) and endorser blocks (EBs) as two +//! vertically-aligned swim lanes sharing one column axis: the newest RBs define +//! the columns (newest at the tip, right), and each EB is drawn in the column of +//! the RB it belongs to — so the shared column *is* the connection. Each EB box +//! shows its transaction download and vote accumulation as mini progress bars, +//! with full figures for the selected EB in a detail strip; a log panel sits +//! below. The EB→RB association is a slot heuristic (no on-wire link exists). //! //! Run with: //! @@ -15,7 +19,7 @@ //! cargo run -p leios-tui //! ``` //! -//! Keys: `q` quit · `↑`/`↓` scroll EBs · `f` toggle follow-tip · `c` clear log. +//! Keys: `q` quit · `←`/`→` select EB · `f` toggle follow-tip · `c` clear log. mod dashboard; mod logbuf; @@ -37,7 +41,7 @@ use pallas_network2::{ interface::TcpInterface, protocol::{Point, handshake::n2n::VersionTable}, }; -use ratatui::{DefaultTerminal, widgets::TableState}; +use ratatui::DefaultTerminal; use tokio::{select, time::Interval}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; @@ -52,8 +56,8 @@ const LEIOS_TESTNET_MAGIC: u64 = 164; /// Chain-sync intersection point, so we follow near the tip instead of replaying /// from origin. The testnet resets periodically; replace with a current point if /// the intersection is not found. -const INTERSECT_SLOT: u64 = 2812236; -const INTERSECT_HASH: &str = "9d8a43aa5ddfa5e2e379ad14b38c3edf98cb6898ed480726fec9da9b68aa3d0e"; +const INTERSECT_SLOT: u64 = 2889961; +const INTERSECT_HASH: &str = "f0221534bd8fa9ec6c7b8c36348718b6a382c40cc39824681a2003af9c820eeb"; struct LeiosNode { network: Manager, InitiatorBehavior, AnyMessage>, @@ -61,7 +65,6 @@ struct LeiosNode { render_interval: Interval, input: EventStream, dashboard: Dashboard, - table_state: TableState, } impl LeiosNode { @@ -86,7 +89,6 @@ impl LeiosNode { render_interval: tokio::time::interval(Duration::from_millis(200)), input: EventStream::new(), dashboard, - table_state: TableState::default(), } } @@ -108,7 +110,7 @@ impl LeiosNode { } async fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> { - terminal.draw(|f| ui::draw(f, &self.dashboard, &mut self.table_state))?; + terminal.draw(|f| ui::draw(f, &self.dashboard))?; loop { select! { @@ -116,7 +118,7 @@ impl LeiosNode { self.network.execute(InitiatorCommand::Housekeeping); } _ = self.render_interval.tick() => { - terminal.draw(|f| ui::draw(f, &self.dashboard, &mut self.table_state))?; + terminal.draw(|f| ui::draw(f, &self.dashboard))?; } evt = self.network.poll_next() => { if let Some(evt) = evt { diff --git a/examples/leios-tui/src/ui.rs b/examples/leios-tui/src/ui.rs index 6a568bf6..0e0011f6 100644 --- a/examples/leios-tui/src/ui.rs +++ b/examples/leios-tui/src/ui.rs @@ -1,5 +1,14 @@ -//! Renders the [`Dashboard`] as a ratatui screen. - +//! Renders the [`Dashboard`] as a ratatui screen — an educational view of the +//! Leios overlay. +//! +//! Ranking blocks (RBs) and endorser blocks (EBs) are two **vertically-aligned +//! swim lanes** sharing one column axis: the newest `N` RBs define the columns +//! (newest hugging the tip on the right), and each EB is drawn in the column of +//! the RB it belongs to. That shared column *is* the connection — an EB sits +//! directly under its RB. The window slides as the tip advances; boxes fill in +//! place. The EB→RB association is a slot heuristic (see [`EbRow::column_rb`]). + +use std::collections::HashMap; use std::time::{Duration, Instant}; use ratatui::{ @@ -7,30 +16,37 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, + widgets::{Block, Borders, Paragraph}, }; -use crate::dashboard::{ChainView, Dashboard, EbRow, EbStage}; +use crate::dashboard::{ChainView, Dashboard, EbRow, EbStage, RbCard}; + +/// Target column box width and inter-column gap, shared by both lanes so their +/// columns line up. Wide enough to fit labelled content; fewer columns fit at +/// once, which is fine — the window follows the tip. +const BOX_W: u16 = 26; +const GAP: u16 = 1; + +/// Height of an RB box (border + hash line + slot line). +const RB_BOX_H: u16 = 4; + +/// Height of an EB card (border + 9 content lines: slot, txs label, tx bar, +/// votes label, vote bar, then one row per lifecycle phase). +const EB_BOX_H: u16 = 11; /// Draws the whole dashboard for the current frame. -pub fn draw(f: &mut Frame, d: &Dashboard, table_state: &mut TableState) { +pub fn draw(f: &mut Frame, d: &Dashboard) { let rows = Layout::vertical([ Constraint::Length(3), // header - Constraint::Length(8), // praos + overlay - Constraint::Min(6), // eb table - Constraint::Length(8), // log + Constraint::Length(7), // RB lane (boxes + rate line) + Constraint::Min(14), // EB lane (tall boxes + detail strip) + Constraint::Length(5), // log Constraint::Length(1), // footer ]) .split(f.area()); render_header(f, d, rows[0]); - - let mid = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]); - render_praos(f, d, mid[0]); - render_overlay(f, d, mid[1]); - - render_ebs(f, d, table_state, rows[2]); + render_lanes(f, d, rows[1], rows[2]); render_log(f, d, rows[3]); render_footer(f, d, rows[4]); } @@ -38,14 +54,18 @@ pub fn draw(f: &mut Frame, d: &Dashboard, table_state: &mut TableState) { fn render_header(f: &mut Frame, d: &Dashboard, area: Rect) { let (dot, status) = if let Some(p) = &d.peer { let leios = if p.leios { "Leios ✓" } else { "Leios ✗" }; + let era = d.chain.era.map(era_name).unwrap_or_else(|| "—".to_string()); ( Span::styled("●", Style::default().fg(Color::Green)), - format!("magic {} N2N v{} {}", d.magic, p.version, leios), + format!( + "connected magic {} N2N v{} {} era {}", + d.magic, p.version, leios, era + ), ) } else { ( Span::styled("○", Style::default().fg(Color::DarkGray)), - format!("magic {} connecting…", d.magic), + format!("connecting… magic {}", d.magic), ) }; @@ -58,140 +78,396 @@ fn render_header(f: &mut Frame, d: &Dashboard, area: Rect) { let line = Line::from(vec![ Span::raw(format!("peer {addr} ")), dot, - Span::raw(format!( - " {status} uptime {}", - fmt_dur(d.started.elapsed()) - )), + Span::raw(format!(" {status}")), ]); let block = Block::default() .borders(Borders::ALL) - .title(" Leios testnet · initiator "); + .title(" Leios · Musashi Dojo ") + .title(Line::from(format!("uptime {} ", fmt_dur(d.started.elapsed()))).right_aligned()); f.render_widget(Paragraph::new(line).block(block), area); } -fn render_praos(f: &mut Frame, d: &Dashboard, area: Rect) { - let c = &d.chain; - let lag = match (c.tip_height, c.local_height) { - (Some(t), Some(l)) => format!("{} blocks", t.saturating_sub(l)), - _ => "—".to_string(), +// --------------------------------------------------------------------------- +// Aligned swim lanes: columns are the newest N RBs; EBs render in their RB's +// column. `rb_area` and `eb_area` are full-width rows, so their inner areas +// share x/width — hence identical column x-positions. +// --------------------------------------------------------------------------- + +/// The RB(s) visible in the columns, plus where each RB `block_no` maps. +struct ColumnPlan<'a> { + cols: usize, + /// `columns[i]` = the RB drawn in column `i` (0 = left, `cols-1` = tip), if any. + columns: Vec>, + /// RB `block_no` → column index, for placing EBs. + col_of: HashMap, +} + +fn plan_columns(chain: &ChainView, inner_width: u16) -> ColumnPlan<'_> { + let cols = (((inner_width + GAP) / (BOX_W + GAP)).max(1)) as usize; + let mut columns: Vec> = vec![None; cols]; + let mut col_of = HashMap::new(); + // Newest RB → rightmost (tip) column; older ones fill leftward. + for (k, rb) in chain.rbs.iter().rev().take(cols).enumerate() { + let col = cols - 1 - k; + columns[col] = Some(rb); + col_of.insert(rb.block_no, col); + } + ColumnPlan { + cols, + columns, + col_of, + } +} + +/// Column x-origin for column `i` within `inner`. +fn column_x(inner: Rect, i: usize) -> u16 { + inner.x + i as u16 * (BOX_W + GAP) +} + +fn render_lanes(f: &mut Frame, d: &Dashboard, rb_area: Rect, eb_area: Rect) { + // Column plan from the (shared) inner width. + let inner_width = rb_area.width.saturating_sub(2); + let plan = plan_columns(&d.chain, inner_width); + let tip_col = plan.cols.saturating_sub(1); + + // EBs newest-first; resolve the selection and the selected EB's column. + let ebs: Vec<&EbRow> = d.ebs.values().rev().collect(); + let sel = if ebs.is_empty() { + None + } else if d.follow { + Some(0) + } else { + Some(d.selected.min(ebs.len() - 1)) }; - let rate = hdr_rate(c, Duration::from_secs(30)); - let bars = spark(&hdr_buckets(c, 16, Duration::from_secs(60))); - let era = c.era.map(era_name).unwrap_or_else(|| "—".to_string()); - - let lines = vec![ - Line::from(format!("tip {}", fmt_pt(c.tip_height, c.tip_slot))), - Line::from(format!("local {}", fmt_pt(c.local_height, c.local_slot))), - Line::from(format!("lag {lag}")), - Line::from(format!("era {era}")), - Line::from(format!("hdr/s {rate:.1} {bars}")), - Line::from(format!( - " headers {} rollbacks {}", - c.headers, c.rollbacks - )), - ]; + let selected_col = sel + .and_then(|i| ebs.get(i)) + .and_then(|row| eb_column(row, &plan.col_of, tip_col)); + + render_rb_lane(f, d, rb_area, &plan, selected_col); + render_eb_lane(f, d, eb_area, &plan, &ebs, sel, selected_col); +} + +/// Which column an EB belongs to: its RB's column, or the tip column while +/// pending. `None` if its RB has scrolled out of the visible window. +fn eb_column(row: &EbRow, col_of: &HashMap, tip_col: usize) -> Option { + match row.column_rb { + Some(bn) => col_of.get(&bn).copied(), + None => Some(tip_col), + } +} +fn render_rb_lane( + f: &mut Frame, + d: &Dashboard, + area: Rect, + plan: &ColumnPlan, + selected_col: Option, +) { + let c = &d.chain; + let tip = format!(" tip {} ", fmt_pt(c.tip_height, c.tip_slot)); let block = Block::default() .borders(Borders::ALL) - .title(" Praos chain "); - f.render_widget(Paragraph::new(lines).block(block), area); + .title(" Ranking blocks — Praos chain ") + .title(Line::from(tip).right_aligned()); + let inner = block.inner(area); + f.render_widget(block, area); + + let split = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner); + let boxes = split[0]; + + if c.rbs.is_empty() { + f.render_widget( + Paragraph::new(Line::from(" waiting for blocks…")) + .style(Style::default().fg(Color::DarkGray)), + boxes, + ); + } else { + let h = boxes.height.min(RB_BOX_H); + for (i, slot) in plan.columns.iter().enumerate() { + let Some(rb) = slot else { continue }; + let x = column_x(boxes, i); + if x + BOX_W > boxes.x + boxes.width { + break; + } + let selected = selected_col == Some(i); + let border = if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + era_color(rb.era) + }; + let b = Block::default() + .borders(Borders::ALL) + .border_style(border) + .title(Line::from(format!("#{}", rb.block_no))); + let rect = Rect { + x, + y: boxes.y, + width: BOX_W, + height: h, + }; + let content = vec![ + Line::from(short_hash(&rb.hash, 8)), + Line::from(Span::styled( + format!("slot {}", group(rb.slot)), + Style::default().fg(Color::DarkGray), + )), + ]; + f.render_widget(Paragraph::new(content).block(b), rect); + } + } + + // Rate / rollback readout beneath the RB boxes. + let rate = hdr_rate(c, Duration::from_secs(30)); + let bars = spark(&hdr_buckets(c, 16, Duration::from_secs(60))); + let line = Line::from(format!( + " headers {} rollbacks {} {:.1} hdr/s {}", + group(c.headers), + group(c.rollbacks), + rate, + bars + )) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(Paragraph::new(line), split[1]); } -fn render_overlay(f: &mut Frame, d: &Dashboard, area: Rect) { +fn render_eb_lane( + f: &mut Frame, + d: &Dashboard, + area: Rect, + plan: &ColumnPlan, + ebs: &[&EbRow], + sel: Option, + selected_col: Option, +) { let o = &d.overlay; - let lines = vec![ - Line::from(format!("announced {}", group(o.announced))), - Line::from(format!( - "offered {} bodies {}", - group(o.offered), - group(o.bodies) - )), - Line::from(format!( - "txs offered {} txs {} / {} tx", - group(o.txs_offered), - group(o.txs_ebs), - group(o.tx_count) - )), - Line::from(format!( - "votes {} voters {}", - group(o.votes), - group(o.voters.len() as u64) - )), - Line::from(format!("fetched {}", human_bytes(o.bytes))), - ]; - + let totals = format!( + " {} seen · {} txs · {} votes ", + group(d.ebs.len() as u64), + group(o.tx_count), + group(o.votes) + ); let block = Block::default() .borders(Borders::ALL) - .title(" Leios overlay "); - f.render_widget(Paragraph::new(lines).block(block), area); + .title(" Endorser blocks — Leios overlay ") + .title(Line::from(totals).right_aligned()); + let inner = block.inner(area); + f.render_widget(block, area); + + let split = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner); + let boxes = split[0]; + let detail_area = split[1]; + + if ebs.is_empty() { + f.render_widget( + Paragraph::new(Line::from(" waiting for endorser blocks…")) + .style(Style::default().fg(Color::DarkGray)), + boxes, + ); + return; + } + + let tip_col = plan.cols.saturating_sub(1); + let scale = d.vote_scale(); + + // Per column: the EB to display (newest mapping there, unless the selected EB + // is in this column) and how many EBs map there (for a `+k` badge). + let mut display: Vec> = vec![None; plan.cols]; + let mut count: Vec = vec![0; plan.cols]; + for (i, row) in ebs.iter().enumerate() { + if let Some(col) = eb_column(row, &plan.col_of, tip_col) { + if display[col].is_none() { + display[col] = Some(i); // newest-first ⇒ first seen is newest + } + count[col] += 1; + } + } + if let (Some(s), Some(col)) = (sel, selected_col) { + display[col] = Some(s); + } + + let h = boxes.height.min(EB_BOX_H); + for (col, slot) in display.iter().enumerate() { + let Some(i) = slot else { continue }; + let x = column_x(boxes, col); + if x + BOX_W > boxes.x + boxes.width { + break; + } + let rect = Rect { + x, + y: boxes.y, + width: BOX_W, + height: h, + }; + render_eb_box( + f, + ebs[*i], + sel == Some(*i), + count[col].saturating_sub(1), + scale, + rect, + ); + } + + // Detail strip for the selected EB. + if let Some(row) = sel.and_then(|i| ebs.get(i)) { + f.render_widget(Paragraph::new(eb_detail(row, &plan.col_of)), detail_area); + } } -fn render_ebs(f: &mut Frame, d: &Dashboard, table_state: &mut TableState, area: Rect) { - let rows: Vec = d - .ebs - .values() - .rev() - .map(|r| { - let hash = if r.hash.len() >= 4 { - format!("{}…", hex::encode(&r.hash[..4])) - } else { - hex::encode(&r.hash) - }; - let size = r - .size - .map(|s| human_bytes(s as u64)) - .unwrap_or_else(|| "—".to_string()); - let txs = match r.tx_total { - Some(n) => format!("{}/{}", r.tx_fetched, n), - None => "—".to_string(), - }; - let (votes, vstyle) = if r.votes > 0 { - (format!("{} ●", r.votes), Style::default().fg(Color::Green)) - } else { - ("—".to_string(), Style::default().fg(Color::DarkGray)) - }; +fn render_eb_box( + f: &mut Frame, + row: &EbRow, + selected: bool, + extra: usize, + scale: usize, + area: Rect, +) { + let badge = if extra > 0 { + format!(" +{extra}") + } else { + String::new() + }; + let border = if selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(border) + .title(Line::from(format!( + "EB {}{badge}", + short_hash(&row.hash, 4) + ))); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } - Row::new(vec![ - Cell::from(group(r.slot)), - Cell::from(hash), - Cell::from(size), - Cell::from(txs), - Cell::from(votes).style(vstyle), - Cell::from(lifecycle(r)), - ]) - }) - .collect(); - - let widths = [ - Constraint::Length(11), - Constraint::Length(11), - Constraint::Length(9), - Constraint::Length(9), - Constraint::Length(7), - Constraint::Min(22), + // Each metric is a `label value` row followed by a full-width bar beneath… + let bar_w = inner.width as usize; + let tx_ratio = match row.tx_total { + Some(0) => 1.0, + Some(t) => row.tx_fetched as f64 / t as f64, + None => 0.0, + }; + let tx_val = match row.tx_total { + Some(t) => format!("{}/{}", row.tx_fetched, t), + None => format!("{}/?", row.tx_fetched), + }; + + let mut lines = vec![ + Line::from(format!("slot {}", group(row.slot))), + Line::from(format!("txs {tx_val}")), + full_bar(tx_ratio, bar_w, Color::Cyan), + Line::from(format!("votes {}/{}", row.voters.len(), scale)), + full_bar(row.vote_ratio(scale), bar_w, Color::Green), ]; - let header = Row::new(vec!["slot", "eb hash", "size", "txs", "votes", "lifecycle"]) - .style(Style::default().add_modifier(Modifier::BOLD)); - let table = Table::new(rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .title(format!(" Endorser Blocks ({}) ", d.ebs.len())), - ) - .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)) - .highlight_symbol("▌"); + // …then one checklist row per lifecycle phase. + for (glyph, label, color) in phase_rows(row) { + lines.push(Line::from(Span::styled( + format!("{glyph} {label}"), + Style::default().fg(color), + ))); + } + f.render_widget(Paragraph::new(lines), inner); +} - let len = d.ebs.len(); - if len == 0 { - table_state.select(None); +/// The EB's lifecycle phases as `(glyph, label, colour)` rows: `✓` done (green), +/// `◐` in progress (yellow), `○` pending (grey). Rendered as a checklist inside +/// each EB box. +fn phase_rows(row: &EbRow) -> [(&'static str, &'static str, Color); 4] { + let done = ("✓", Color::Green); + let active = ("◐", Color::Yellow); + let pending = ("○", Color::DarkGray); + + // Offered is implied by the box existing. Body is fetched automatically after + // the offer, so it's "active" until it lands. + let body = if row.stage >= EbStage::BodyFetched { + done } else { - let sel = if d.follow { 0 } else { d.selected.min(len - 1) }; - table_state.select(Some(sel)); + active + }; + let txs = match row.tx_total { + Some(0) => done, + Some(t) if row.tx_fetched >= t => done, + _ if row.tx_fetched > 0 || row.stage >= EbStage::TxsOffered => active, + _ => pending, + }; + let votes = if row.votes > 0 { done } else { pending }; + + [ + (done.0, "offered", done.1), + (body.0, "body", body.1), + (txs.0, "download txs", txs.1), + (votes.0, "votes", votes.1), + ] +} + +/// A full-width progress bar (`████░░░░`) — the label and value sit on the row +/// above it (see [`render_eb_box`]). +fn full_bar(ratio: f64, width: usize, color: Color) -> Line<'static> { + let width = width.max(1); + let filled = ((ratio.clamp(0.0, 1.0) * width as f64).round() as usize).min(width); + let mut bar = String::with_capacity(width); + for _ in 0..filled { + bar.push('█'); + } + for _ in filled..width { + bar.push('░'); } + Line::from(Span::styled(bar, Style::default().fg(color))) +} - f.render_stateful_widget(table, area, table_state); +/// One-line full readout of the selected EB. +fn eb_detail(row: &EbRow, col_of: &HashMap) -> Line<'static> { + let hash = if row.hash.len() >= 6 { + format!("{}…", hex::encode(&row.hash[..6])) + } else { + hex::encode(&row.hash) + }; + let size = row + .size + .map(|s| human_bytes(s as u64)) + .unwrap_or_else(|| "—".to_string()); + let txs = match row.tx_total { + Some(t) => format!("{}/{}", row.tx_fetched, t), + None => format!("{}/?", row.tx_fetched), + }; + let rb = match row.column_rb { + Some(bn) if col_of.contains_key(&bn) => format!("RB #{bn}"), + Some(bn) => format!("RB #{bn} (off-window)"), + None => "RB pending".to_string(), + }; + let (_, stage, color) = stage_glyph(row); + Line::from(vec![ + Span::raw(format!( + " selected EB {hash} · slot {} · {size} · txs {txs} · votes {} · ", + group(row.slot), + row.voters.len() + )), + Span::styled(stage.to_string(), Style::default().fg(color)), + Span::raw(format!(" · {rb}")), + ]) +} + +/// The EB's lifecycle stage as a `(glyph, label, colour)` triple. `voting` +/// (any votes seen) takes precedence over the fetch stage. +fn stage_glyph(row: &EbRow) -> (&'static str, &'static str, Color) { + if row.votes > 0 { + return ("●", "voting", Color::Green); + } + match row.stage { + EbStage::Offered => ("○", "offered", Color::DarkGray), + EbStage::BodyFetched | EbStage::TxsOffered => ("◐", "downloading", Color::Yellow), + EbStage::TxsFetched => ("◑", "txs complete", Color::Cyan), + } } fn render_log(f: &mut Frame, d: &Dashboard, area: Rect) { @@ -214,62 +490,12 @@ fn render_log(f: &mut Frame, d: &Dashboard, area: Rect) { fn render_footer(f: &mut Frame, d: &Dashboard, area: Rect) { let follow = if d.follow { "on" } else { "off" }; let line = Line::from(format!( - " q quit ↑/↓ scroll f follow:{follow} c clear log " + " q quit ←/→ select EB f follow:{follow} c clear log · column = nearest RB by slot (heuristic) · vote bar ∝ peak voters" )) .style(Style::default().fg(Color::DarkGray)); f.render_widget(Paragraph::new(line), area); } -/// Builds the lifecycle chip for an EB row: reached stages bright, pending dim, -/// with a short hint naming the next awaited step. -fn lifecycle(row: &EbRow) -> Line<'static> { - let reached = |on: bool, label: &str| { - let style = if on { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }; - Span::styled(label.to_string(), style) - }; - let sep = || Span::styled("▸".to_string(), Style::default().fg(Color::DarkGray)); - - let body = row.stage >= EbStage::BodyFetched; - let txs = row.stage >= EbStage::TxsFetched; - let voted = row.votes > 0; - - let mut spans = vec![ - reached(true, "offer"), - sep(), - reached(body, "body"), - sep(), - reached(txs, "txs"), - sep(), - reached(voted, "vote"), - ]; - - let hint = if !body { - " awaiting body" - } else if row.stage < EbStage::TxsOffered { - " awaiting offer" - } else if !txs { - " txs offered" - } else { - "" - }; - if !hint.is_empty() { - spans.push(Span::styled( - hint.to_string(), - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - )); - } - - Line::from(spans) -} - // --------------------------------------------------------------------------- // formatting helpers // --------------------------------------------------------------------------- @@ -301,6 +527,14 @@ fn group(n: u64) -> String { out } +/// First `nbytes` of a hash as hex with an ellipsis (`—` if empty). +fn short_hash(hash: &[u8], nbytes: usize) -> String { + if hash.is_empty() { + return "—".to_string(); + } + format!("{}…", hex::encode(&hash[..nbytes.min(hash.len())])) +} + fn human_bytes(n: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; let mut v = n as f64; @@ -331,6 +565,17 @@ fn era_name(variant: u8) -> String { format!("{name} ({variant})") } +/// Border tint for an RB box, keyed to its era. +fn era_color(variant: u8) -> Style { + let color = match variant { + 7 => Color::Magenta, // Dijkstra + 6 => Color::Blue, // Conway + 5 => Color::Cyan, // Babbage + _ => Color::DarkGray, + }; + Style::default().fg(color) +} + /// Header arrivals per bucket over `window`, oldest-left / newest-right. fn hdr_buckets(chain: &ChainView, n: usize, window: Duration) -> Vec { let now = Instant::now(); @@ -370,3 +615,79 @@ fn spark(data: &[u64]) -> String { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dashboard::{EbRow, EbStage, RbCard}; + use pallas_network2::protocol::Point; + use ratatui::{Terminal, backend::TestBackend}; + use std::collections::HashSet; + + fn draw_at(d: &Dashboard, w: u16, h: u16) { + let mut term = Terminal::new(TestBackend::new(w, h)).unwrap(); + term.draw(|f| draw(f, d)).unwrap(); + } + + /// A dashboard with RBs and EBs linked to columns (plus one pending EB), so + /// rendering exercises the aligned-lane paths. + fn populated() -> Dashboard { + let mut d = Dashboard::new("relay:3001".into(), 164, crate::logbuf::new_log()); + d.chain.tip_height = Some(12_043); + d.chain.tip_slot = Some(2_814_902); + d.chain.era = Some(7); + for i in 0..20u64 { + d.chain.rbs.push_back(RbCard { + block_no: 12_000 + i, + slot: 2_814_000 + i * 10, + era: 7, + hash: vec![i as u8; 32], + }); + } + for i in 0..8u64 { + let hash = vec![i as u8; 32]; + let voters: HashSet = (0..i).collect(); + // Link most EBs to a recent RB; leave the last one pending. + let column_rb = if i < 7 { Some(12_012 + i) } else { None }; + d.ebs.insert( + Point::Specific(2_814_120 + i * 10, hash.clone()), + EbRow { + slot: 2_814_120 + i * 10, + hash, + size: Some(8_192), + tx_total: Some(64), + tx_fetched: (i * 8) as usize, + votes: i as usize, + voters, + stage: EbStage::TxsFetched, + column_rb, + }, + ); + } + d.peak_voters = 7; + d + } + + #[test] + fn draw_never_panics_across_sizes_and_states() { + let sizes = [(80, 24), (120, 40), (40, 12), (200, 60), (30, 8)]; + + let empty = Dashboard::new("relay:3001".into(), 164, crate::logbuf::new_log()); + for (w, h) in sizes { + draw_at(&empty, w, h); + } + + let full = populated(); + for (w, h) in sizes { + draw_at(&full, w, h); + } + + // Follow off with a selection past the end (clamped) and a narrow width + // that forces few columns. + let mut scrolled = populated(); + scrolled.follow = false; + scrolled.selected = 999; + draw_at(&scrolled, 100, 30); + draw_at(&scrolled, 28, 20); + } +}