Skip to main content

bee_tui/components/
stamps.rs

1//! S2 — Stamps screen (`docs/PLAN.md` § 8.S2).
2//!
3//! Renders one row per known postage batch with the volume +
4//! duration framing operators actually reason about (bee#4992 is
5//! retiring depth+amount). The "worst bucket" column tells the
6//! truth that the API's `utilization` field is `MaxBucketCount`
7//! — operators see exactly which batch is about to fail uploads
8//! even though average usage is far from 100%.
9//!
10//! Behaviour is data-driven via [`Stamps::rows_for`] so insta
11//! snapshot tests can stub the input and verify status / value /
12//! `why` strings without launching a TUI.
13
14use color_eyre::Result;
15use ratatui::{
16    Frame,
17    layout::{Constraint, Layout, Rect},
18    style::{Color, Modifier, Style},
19    text::{Line, Span},
20    widgets::{Block, Borders, Paragraph},
21};
22use tokio::sync::watch;
23
24use super::Component;
25use crate::action::Action;
26use crate::watch::StampsSnapshot;
27
28use bee::postage::PostageBatch;
29
30/// Tri-state row outcome with `Pending` for chain-confirmation gating.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum StampStatus {
33    /// `usable=false` — chain hasn't confirmed the batch yet.
34    Pending,
35    /// `batch_ttl ≤ 0` — paid balance is exhausted, nothing to stamp.
36    Expired,
37    /// Worst bucket ≥ 95 % — the very next upload may fail
38    /// (immutable batches return ErrBucketFull at the upper bound).
39    Critical,
40    /// Worst bucket ≥ 80 %. Above the safe-headroom line; warn early.
41    Skewed,
42    /// Everything green: usable, in budget, headroom present.
43    Healthy,
44}
45
46impl StampStatus {
47    fn color(self) -> Color {
48        match self {
49            Self::Pending => Color::Cyan,
50            Self::Expired => Color::Red,
51            Self::Critical => Color::Red,
52            Self::Skewed => Color::Yellow,
53            Self::Healthy => Color::Green,
54        }
55    }
56    fn label(self) -> &'static str {
57        match self {
58            Self::Pending => "⏳ pending",
59            Self::Expired => "✗ expired",
60            Self::Critical => "✗ critical",
61            Self::Skewed => "⚠ skewed",
62            Self::Healthy => "✓",
63        }
64    }
65}
66
67/// One row of the stamps table.
68#[derive(Debug, Clone)]
69pub struct StampRow {
70    pub label: String,
71    pub batch_id_short: String,
72    /// Theoretical volume = `2^depth × 4 KiB`, formatted to a
73    /// human-readable string. Effective volume is bounded by the
74    /// worst bucket — see `worst_bucket_pct`.
75    pub volume: String,
76    /// Worst-bucket fill percentage in `0..=100`. This *is* what the
77    /// API calls `utilization`; operators don't always realise.
78    pub worst_bucket_pct: u32,
79    /// Raw `utilization` count plus `BucketUpperBound`.
80    pub worst_bucket_raw: String,
81    /// Pre-formatted `Xd Yh` countdown string. `"-"` if expired.
82    pub ttl: String,
83    /// `true` if `immutable` — flagged in the `value` line because
84    /// mutable + full silently overwrites prior chunks (bee#5334).
85    pub immutable: bool,
86    pub status: StampStatus,
87    /// Inline tooltip rendered on the continuation line.
88    pub why: Option<String>,
89}
90
91pub struct Stamps {
92    rx: watch::Receiver<StampsSnapshot>,
93    snapshot: StampsSnapshot,
94}
95
96impl Stamps {
97    pub fn new(rx: watch::Receiver<StampsSnapshot>) -> Self {
98        let snapshot = rx.borrow().clone();
99        Self { rx, snapshot }
100    }
101
102    fn pull_latest(&mut self) {
103        self.snapshot = self.rx.borrow().clone();
104    }
105
106    /// Pure, snapshot-driven row computation. Exposed for snapshot
107    /// tests.
108    pub fn rows_for(snap: &StampsSnapshot) -> Vec<StampRow> {
109        snap.batches.iter().map(row_from_batch).collect()
110    }
111}
112
113fn row_from_batch(b: &PostageBatch) -> StampRow {
114    let label = if b.label.is_empty() {
115        "(unlabeled)".to_string()
116    } else {
117        b.label.clone()
118    };
119    let batch_hex = b.batch_id.to_hex();
120    let batch_id_short = if batch_hex.len() > 8 {
121        format!("{}…", &batch_hex[..8])
122    } else {
123        batch_hex
124    };
125    let theoretical_bytes: u128 = (1u128 << b.depth) * 4096;
126    let volume = format_bytes(theoretical_bytes);
127    let worst_bucket_pct = worst_bucket_pct(b);
128    let upper_bound = 1u32 << b.depth.saturating_sub(b.bucket_depth);
129    let worst_bucket_raw = format!("{}/{}", b.utilization, upper_bound);
130    let ttl = format_ttl_seconds(b.batch_ttl);
131
132    let (status, why) = if !b.usable {
133        (
134            StampStatus::Pending,
135            Some("waiting on chain confirmation (~10 blocks).".into()),
136        )
137    } else if b.batch_ttl <= 0 {
138        (
139            StampStatus::Expired,
140            Some("paid balance exhausted; topup or stop using.".into()),
141        )
142    } else if worst_bucket_pct >= 95 {
143        (
144            StampStatus::Critical,
145            Some(if b.immutable {
146                "immutable batch will REJECT next upload at this bucket.".into()
147            } else {
148                "mutable batch will silently overwrite oldest chunks.".into()
149            }),
150        )
151    } else if worst_bucket_pct >= 80 {
152        (
153            StampStatus::Skewed,
154            Some(format!(
155                "worst bucket {worst_bucket_pct}% > safe headroom — dilute or stop using."
156            )),
157        )
158    } else {
159        (StampStatus::Healthy, None)
160    };
161
162    StampRow {
163        label,
164        batch_id_short,
165        volume,
166        worst_bucket_pct,
167        worst_bucket_raw,
168        ttl,
169        immutable: b.immutable,
170        status,
171        why,
172    }
173}
174
175/// `MaxBucketCount` (Bee's `utilization`) as a 0..=100 percentage of
176/// the per-bucket upper bound `2^(depth - bucket_depth)`.
177fn worst_bucket_pct(b: &PostageBatch) -> u32 {
178    let upper_bound: u32 = 1u32 << b.depth.saturating_sub(b.bucket_depth);
179    if upper_bound == 0 {
180        0
181    } else {
182        let pct = (u64::from(b.utilization) * 100) / u64::from(upper_bound);
183        pct.min(100) as u32
184    }
185}
186
187/// Bytes → IEC binary (KiB / MiB / GiB / TiB).
188fn format_bytes(bytes: u128) -> String {
189    const K: u128 = 1024;
190    const M: u128 = K * 1024;
191    const G: u128 = M * 1024;
192    const T: u128 = G * 1024;
193    if bytes >= T {
194        format!("{:.1} TiB", bytes as f64 / T as f64)
195    } else if bytes >= G {
196        format!("{:.1} GiB", bytes as f64 / G as f64)
197    } else if bytes >= M {
198        format!("{:.1} MiB", bytes as f64 / M as f64)
199    } else if bytes >= K {
200        format!("{:.1} KiB", bytes as f64 / K as f64)
201    } else {
202        format!("{bytes} B")
203    }
204}
205
206fn format_ttl_seconds(secs: i64) -> String {
207    if secs <= 0 {
208        return "expired".into();
209    }
210    let days = secs / 86_400;
211    let hours = (secs % 86_400) / 3_600;
212    if days >= 1 {
213        format!("{days}d {hours:>2}h")
214    } else {
215        let minutes = (secs % 3_600) / 60;
216        format!("{hours}h {minutes:>2}m")
217    }
218}
219
220/// 8-character ASCII fill bar.
221fn fill_bar(pct: u32, width: usize) -> String {
222    let filled = ((pct as usize) * width) / 100;
223    let mut bar = String::with_capacity(width);
224    for _ in 0..filled.min(width) {
225        bar.push('▇');
226    }
227    for _ in filled.min(width)..width {
228        bar.push('░');
229    }
230    bar
231}
232
233impl Component for Stamps {
234    fn update(&mut self, action: Action) -> Result<Option<Action>> {
235        if matches!(action, Action::Tick) {
236            self.pull_latest();
237        }
238        Ok(None)
239    }
240
241    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
242        let chunks = Layout::vertical([
243            Constraint::Length(3), // header
244            Constraint::Min(0),    // table
245            Constraint::Length(1), // footer
246        ])
247        .split(area);
248
249        // Header
250        let count = self.snapshot.batches.len();
251        let header_l1 = Line::from(vec![
252            Span::styled("STAMPS", Style::default().add_modifier(Modifier::BOLD)),
253            Span::raw(format!("  {count} batch(es)")),
254        ]);
255        let mut header_l2 = Vec::new();
256        if let Some(err) = &self.snapshot.last_error {
257            header_l2.push(Span::styled(
258                format!("error: {err}"),
259                Style::default().fg(Color::Red),
260            ));
261        } else if !self.snapshot.is_loaded() {
262            header_l2.push(Span::styled(
263                "loading…",
264                Style::default().fg(Color::DarkGray),
265            ));
266        }
267        frame.render_widget(
268            Paragraph::new(vec![header_l1, Line::from(header_l2)])
269                .block(Block::default().borders(Borders::BOTTOM)),
270            chunks[0],
271        );
272
273        // Table (rendered as a Paragraph of styled Lines for control)
274        let mut lines: Vec<Line> = Vec::new();
275        // Column header
276        lines.push(Line::from(vec![Span::styled(
277            "  LABEL                BATCH        VOLUME      WORST BUCKET                TTL         STATUS",
278            Style::default()
279                .fg(Color::DarkGray)
280                .add_modifier(Modifier::BOLD),
281        )]));
282        if self.snapshot.batches.is_empty() {
283            lines.push(Line::from(Span::styled(
284                "  (no batches yet — buy one with swarm-cli or `bee stamps buy`)",
285                Style::default()
286                    .fg(Color::DarkGray)
287                    .add_modifier(Modifier::ITALIC),
288            )));
289        } else {
290            for r in Self::rows_for(&self.snapshot) {
291                let bar = fill_bar(r.worst_bucket_pct, 8);
292                let immut_glyph = if r.immutable { "I" } else { "M" };
293                lines.push(Line::from(vec![
294                    Span::raw("  "),
295                    Span::styled(
296                        format!("{:<20}", truncate(&r.label, 20)),
297                        Style::default().add_modifier(Modifier::BOLD),
298                    ),
299                    Span::raw(format!("{:<13}", r.batch_id_short)),
300                    Span::raw(format!("{:<12}", r.volume)),
301                    Span::styled(
302                        format!("{bar} {:>3}% ({})", r.worst_bucket_pct, r.worst_bucket_raw),
303                        Style::default().fg(bucket_color(r.worst_bucket_pct)),
304                    ),
305                    Span::raw("    "),
306                    Span::raw(format!("{:<10} ", r.ttl)),
307                    Span::styled(immut_glyph, Style::default().fg(Color::DarkGray)),
308                    Span::raw(" "),
309                    Span::styled(
310                        r.status.label(),
311                        Style::default()
312                            .fg(r.status.color())
313                            .add_modifier(Modifier::BOLD),
314                    ),
315                ]));
316                if let Some(why) = r.why {
317                    lines.push(Line::from(vec![
318                        Span::raw("       └─ "),
319                        Span::styled(
320                            why,
321                            Style::default()
322                                .fg(Color::DarkGray)
323                                .add_modifier(Modifier::ITALIC),
324                        ),
325                    ]));
326                }
327            }
328        }
329        frame.render_widget(Paragraph::new(lines), chunks[1]);
330
331        // Footer
332        frame.render_widget(
333            Paragraph::new(Line::from(vec![
334                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
335                Span::raw(" switch screen  "),
336                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
337                Span::raw(" quit  "),
338                Span::styled(" I/M ", Style::default().fg(Color::DarkGray)),
339                Span::raw(" immutable / mutable "),
340            ])),
341            chunks[2],
342        );
343
344        Ok(())
345    }
346}
347
348fn truncate(s: &str, max: usize) -> String {
349    if s.chars().count() <= max {
350        s.to_string()
351    } else {
352        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
353        out.push('…');
354        out
355    }
356}
357
358fn bucket_color(pct: u32) -> Color {
359    if pct >= 95 {
360        Color::Red
361    } else if pct >= 80 {
362        Color::Yellow
363    } else {
364        Color::Green
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn fill_bar_clamps_to_width() {
374        assert_eq!(fill_bar(0, 8), "░░░░░░░░");
375        assert_eq!(fill_bar(50, 8), "▇▇▇▇░░░░");
376        assert_eq!(fill_bar(100, 8), "▇▇▇▇▇▇▇▇");
377        assert_eq!(fill_bar(150, 8), "▇▇▇▇▇▇▇▇"); // saturating
378    }
379
380    #[test]
381    fn format_bytes_iec() {
382        assert_eq!(format_bytes(0), "0 B");
383        assert_eq!(format_bytes(1024), "1.0 KiB");
384        assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
385        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GiB");
386        assert_eq!(format_bytes(16 * 1024 * 1024 * 1024), "16.0 GiB");
387    }
388
389    #[test]
390    fn format_ttl_zero_is_expired() {
391        assert_eq!(format_ttl_seconds(0), "expired");
392        assert_eq!(format_ttl_seconds(-5), "expired");
393    }
394
395    #[test]
396    fn format_ttl_days_and_hours() {
397        // 47d 12h
398        assert_eq!(format_ttl_seconds(47 * 86_400 + 12 * 3_600), "47d 12h");
399    }
400
401    #[test]
402    fn format_ttl_under_a_day_uses_hours_minutes() {
403        assert_eq!(format_ttl_seconds(2 * 3_600 + 30 * 60), "2h 30m");
404    }
405}