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//! `Enter` on a selected row drills into the per-bucket histogram
11//! (`/stamps/{id}/buckets`): the batch ID alone tells you the
12//! worst bucket, but the drill answers the next operator question
13//! — *how concentrated* is the load? A batch where 98 buckets are
14//! at 90% behaves very differently from one where two are at 100%
15//! and the rest are near-empty.
16//!
17//! Behaviour is data-driven via [`Stamps::rows_for`] and
18//! [`Stamps::compute_drill_view`] so insta snapshot tests can stub
19//! the input and verify status / value / `why` strings without
20//! launching a TUI.
21
22use std::sync::Arc;
23
24use color_eyre::Result;
25use crossterm::event::{KeyCode, KeyEvent};
26use ratatui::{
27    Frame,
28    layout::{Constraint, Layout, Rect},
29    style::{Color, Modifier, Style},
30    text::{Line, Span},
31    widgets::{Block, Borders, Paragraph},
32};
33use tokio::sync::{mpsc, watch};
34
35use super::Component;
36use crate::action::Action;
37use crate::api::ApiClient;
38use crate::theme;
39use crate::watch::StampsSnapshot;
40
41use bee::postage::{BatchBucket, PostageBatch, PostageBatchBuckets};
42use bee::swarm::BatchId;
43
44/// Tri-state row outcome with `Pending` for chain-confirmation gating.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum StampStatus {
47    /// `usable=false` — chain hasn't confirmed the batch yet.
48    Pending,
49    /// `batch_ttl ≤ 0` — paid balance is exhausted, nothing to stamp.
50    Expired,
51    /// Worst bucket ≥ 95 % — the very next upload may fail
52    /// (immutable batches return ErrBucketFull at the upper bound).
53    Critical,
54    /// Worst bucket ≥ 80 %. Above the safe-headroom line; warn early.
55    Skewed,
56    /// Everything green: usable, in budget, headroom present.
57    Healthy,
58}
59
60impl StampStatus {
61    fn color(self) -> Color {
62        let t = theme::active();
63        match self {
64            Self::Pending => t.info,
65            Self::Expired => t.fail,
66            Self::Critical => t.fail,
67            Self::Skewed => t.warn,
68            Self::Healthy => t.pass,
69        }
70    }
71    fn label(self) -> &'static str {
72        match self {
73            Self::Pending => "⏳ pending",
74            Self::Expired => "✗ expired",
75            Self::Critical => "✗ critical",
76            Self::Skewed => "⚠ skewed",
77            Self::Healthy => "✓",
78        }
79    }
80}
81
82/// One row of the stamps table.
83#[derive(Debug, Clone)]
84pub struct StampRow {
85    pub label: String,
86    pub batch_id_short: String,
87    /// Theoretical volume = `2^depth × 4 KiB`, formatted to a
88    /// human-readable string. Effective volume is bounded by the
89    /// worst bucket — see `worst_bucket_pct`.
90    pub volume: String,
91    /// Worst-bucket fill percentage in `0..=100`. This *is* what the
92    /// API calls `utilization`; operators don't always realise.
93    pub worst_bucket_pct: u32,
94    /// Raw `utilization` count plus `BucketUpperBound`.
95    pub worst_bucket_raw: String,
96    /// Pre-formatted `Xd Yh` countdown string. `"-"` if expired.
97    pub ttl: String,
98    /// Raw seconds until expiry. Drives the TTL-threshold colour on
99    /// the row even when the formatted string already coalesces to
100    /// "-" / "expired".
101    pub ttl_seconds: i64,
102    /// `true` if `immutable` — flagged in the `value` line because
103    /// mutable + full silently overwrites prior chunks (bee#5334).
104    pub immutable: bool,
105    pub status: StampStatus,
106    /// Inline tooltip rendered on the continuation line.
107    pub why: Option<String>,
108}
109
110/// TTL-threshold constants. Below `TOPUP_SOON_SECS` we suggest
111/// topup in the row's `why` line; below `TOPUP_URGENT_SECS` we
112/// escalate to a critical / red row. Values chosen so a healthy
113/// batch never trips them: 7 days is well above the typical chain-
114/// confirmation lag, and 24 h is the operator's last reaction window
115/// before the batch expires.
116pub const TOPUP_SOON_SECS: i64 = 7 * 24 * 3600;
117pub const TOPUP_URGENT_SECS: i64 = 24 * 3600;
118
119/// Bucket fill distribution for [`StampDrillView`]. Six buckets keep
120/// the display compact while still distinguishing "nearly full" from
121/// "actually full". Ordered low → high.
122pub const FILL_BIN_LABELS: &[&str] = &[
123    "0 %",
124    "1 – 19 %",
125    "20 – 49 %",
126    "50 – 79 %",
127    "80 – 99 %",
128    "100 %",
129];
130
131/// Aggregated drill view for the bucket histogram screen. Pure —
132/// computed from [`PostageBatchBuckets`] without any I/O.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct StampDrillView {
135    pub depth: u8,
136    pub bucket_depth: u8,
137    pub upper_bound: u32,
138    /// Sum of every bucket's collisions count (the chunks-stamped
139    /// total Bee tracks for the batch).
140    pub total_chunks: u64,
141    /// `2^bucket_depth × upper_bound` — the headline "what the batch
142    /// could hold if perfectly distributed" number. Computed in
143    /// `u128` to dodge overflow on max-depth batches.
144    pub theoretical_capacity: u128,
145    /// Count of buckets whose fill percentage falls in each
146    /// [`FILL_BIN_LABELS`] bin. `[u32; 6]` matches the bin labels
147    /// 1-for-1.
148    pub fill_distribution: [u32; 6],
149    /// Up to 10 worst buckets sorted by collisions descending.
150    /// Stable ordering: ties broken by bucket-id ascending.
151    pub worst_buckets: Vec<WorstBucket>,
152    /// Worst single bucket fill percentage (matches the row's
153    /// `worst_bucket_pct`).
154    pub worst_pct: u32,
155    /// Predicted economics — populated from the batch's `amount` and
156    /// `depth` per the canonical `bzz = amount × 2^depth / 1e16`
157    /// formula used by swarm-cli (`stamp/buy.ts:97-103`),
158    /// beekeeper-stamper (`pkg/stamper/node.go:33-43`), and
159    /// gateway-proxy (`stamps.ts:198-234`). `None` when Bee didn't
160    /// supply `amount` (very rare; usually for not-yet-confirmed
161    /// batches).
162    pub economics: Option<StampEconomics>,
163}
164
165/// Predictive economics computed for a batch, all rendered as
166/// formatted strings so the renderer doesn't hold BigInt / f64.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct StampEconomics {
169    /// Total BZZ pre-paid for the batch — `amount × 2^depth / 1e16`.
170    /// Formatted with 4 decimal places.
171    pub bzz_paid: String,
172    /// Effective storage in bytes (theoretical, before bucket
173    /// skew). Same value as `theoretical_capacity` × 4096; included
174    /// here as a humanised string ("16.0 GiB") for the drill header.
175    pub volume_humanised: String,
176    /// Cost per GiB of theoretical capacity, in BZZ. Useful for
177    /// comparing batches with different depths at a glance.
178    pub bzz_per_gib: String,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub struct WorstBucket {
183    pub bucket_id: u32,
184    pub collisions: u32,
185    pub pct: u32,
186}
187
188/// Drill-pane state machine. `Idle` keeps the regular table
189/// rendered; the other variants replace it with the drill view.
190#[derive(Debug, Clone)]
191pub enum DrillState {
192    Idle,
193    Loading {
194        batch_id: BatchId,
195    },
196    Loaded {
197        batch_id: BatchId,
198        view: StampDrillView,
199    },
200    Failed {
201        batch_id: BatchId,
202        error: String,
203    },
204}
205
206type DrillFetchResult = (BatchId, std::result::Result<PostageBatchBuckets, String>);
207
208pub struct Stamps {
209    client: Arc<ApiClient>,
210    rx: watch::Receiver<StampsSnapshot>,
211    snapshot: StampsSnapshot,
212    selected: usize,
213    /// Visual-line scroll offset for the table body. Updated lazily
214    /// inside `draw_table`. Continuations (the `why` tooltip lines)
215    /// count as additional visual lines so the offset is in lines,
216    /// not rows.
217    scroll_offset: usize,
218    drill: DrillState,
219    fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
220    fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
221}
222
223impl Stamps {
224    pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<StampsSnapshot>) -> Self {
225        let snapshot = rx.borrow().clone();
226        let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
227        Self {
228            client,
229            rx,
230            snapshot,
231            selected: 0,
232            scroll_offset: 0,
233            drill: DrillState::Idle,
234            fetch_tx,
235            fetch_rx,
236        }
237    }
238
239    fn pull_latest(&mut self) {
240        self.snapshot = self.rx.borrow().clone();
241        // If batches disappear/shrink, clamp the selection so we don't
242        // dangle an out-of-bounds index.
243        let n = self.snapshot.batches.len();
244        if n == 0 {
245            self.selected = 0;
246        } else if self.selected >= n {
247            self.selected = n - 1;
248        }
249    }
250
251    /// Drain any drill fetches that completed since the last tick.
252    /// Late results from a since-cancelled drill (operator hit Esc
253    /// then Enter on a different row before the network came back)
254    /// are dropped silently — `drill` already moved on.
255    fn drain_fetches(&mut self) {
256        while let Ok((batch_id, result)) = self.fetch_rx.try_recv() {
257            match &self.drill {
258                DrillState::Loading { batch_id: pending } if *pending == batch_id => {}
259                _ => continue, // user moved on; ignore
260            }
261            self.drill = match result {
262                Ok(buckets) => {
263                    let batch = self
264                        .snapshot
265                        .batches
266                        .iter()
267                        .find(|b| b.batch_id == batch_id);
268                    DrillState::Loaded {
269                        batch_id,
270                        view: Self::compute_drill_view(&buckets, batch),
271                    }
272                }
273                Err(error) => DrillState::Failed { batch_id, error },
274            };
275        }
276    }
277
278    /// Pure, snapshot-driven row computation. Exposed for snapshot
279    /// tests.
280    pub fn rows_for(snap: &StampsSnapshot) -> Vec<StampRow> {
281        snap.batches.iter().map(row_from_batch).collect()
282    }
283
284    /// Pure compute path for the drill pane. Buckets the per-bucket
285    /// collisions into [`FILL_BIN_LABELS`] bins, picks the top-10
286    /// worst, totals the chunk count. `batch` is optional — when
287    /// supplied, predicted economics (`bzz_paid` etc.) are derived
288    /// from `batch.amount` × `2^depth` and rendered in the drill
289    /// header. Tests that only care about bucket shape can pass
290    /// `None`.
291    pub fn compute_drill_view(
292        buckets: &PostageBatchBuckets,
293        batch: Option<&PostageBatch>,
294    ) -> StampDrillView {
295        let upper_bound = buckets.bucket_upper_bound.max(1);
296        let mut fill_distribution = [0u32; 6];
297        let mut total_chunks: u64 = 0;
298        for b in &buckets.buckets {
299            total_chunks += u64::from(b.collisions);
300            let bin = bucket_fill_bin(b.collisions, upper_bound);
301            fill_distribution[bin] += 1;
302        }
303        let mut sorted: Vec<&BatchBucket> = buckets.buckets.iter().collect();
304        // Worst first; ties broken by bucket-id ascending so the list
305        // is deterministic regardless of how Bee returns the array.
306        sorted.sort_by(|a, b| {
307            b.collisions
308                .cmp(&a.collisions)
309                .then_with(|| a.bucket_id.cmp(&b.bucket_id))
310        });
311        let worst_buckets: Vec<WorstBucket> = sorted
312            .iter()
313            .take(10)
314            .map(|b| WorstBucket {
315                bucket_id: b.bucket_id,
316                collisions: b.collisions,
317                pct: pct_of(b.collisions, upper_bound),
318            })
319            .collect();
320        let worst_pct = worst_buckets.first().map(|w| w.pct).unwrap_or(0);
321        let theoretical_capacity = (1u128 << buckets.bucket_depth) * u128::from(upper_bound);
322        let economics = batch.and_then(compute_stamp_economics);
323        StampDrillView {
324            depth: buckets.depth,
325            bucket_depth: buckets.bucket_depth,
326            upper_bound,
327            total_chunks,
328            theoretical_capacity,
329            fill_distribution,
330            worst_buckets,
331            worst_pct,
332            economics,
333        }
334    }
335
336    /// Spawn a background fetch for the batch under the cursor.
337    /// No-op if there are no batches or a fetch is already in
338    /// flight for the same batch.
339    fn maybe_start_drill(&mut self) {
340        if self.snapshot.batches.is_empty() {
341            return;
342        }
343        let i = self.selected.min(self.snapshot.batches.len() - 1);
344        let batch_id = self.snapshot.batches[i].batch_id;
345        if let DrillState::Loading { batch_id: pending } = &self.drill {
346            if *pending == batch_id {
347                return; // already in flight
348            }
349        }
350        let client = self.client.clone();
351        let tx = self.fetch_tx.clone();
352        tokio::spawn(async move {
353            let res = client
354                .bee()
355                .postage()
356                .get_postage_batch_buckets(&batch_id)
357                .await
358                .map_err(|e| e.to_string());
359            let _ = tx.send((batch_id, res));
360        });
361        self.drill = DrillState::Loading { batch_id };
362    }
363}
364
365fn bucket_fill_bin(collisions: u32, upper_bound: u32) -> usize {
366    if collisions == 0 {
367        return 0;
368    }
369    if collisions >= upper_bound {
370        return 5; // 100 % (and over-saturated edge — Bee can over-report)
371    }
372    let pct = pct_of(collisions, upper_bound);
373    match pct {
374        0 => 0, // belt-and-braces; the early return handles 0
375        1..=19 => 1,
376        20..=49 => 2,
377        50..=79 => 3,
378        80..=99 => 4,
379        _ => 5,
380    }
381}
382
383fn pct_of(collisions: u32, upper_bound: u32) -> u32 {
384    if upper_bound == 0 {
385        return 0;
386    }
387    let pct = (u64::from(collisions) * 100) / u64::from(upper_bound);
388    pct.min(100) as u32
389}
390
391fn row_from_batch(b: &PostageBatch) -> StampRow {
392    let label = if b.label.is_empty() {
393        "(unlabeled)".to_string()
394    } else {
395        b.label.clone()
396    };
397    let batch_hex = b.batch_id.to_hex();
398    let batch_id_short = if batch_hex.len() > 8 {
399        format!("{}…", &batch_hex[..8])
400    } else {
401        batch_hex
402    };
403    let theoretical_bytes: u128 = (1u128 << b.depth) * 4096;
404    let volume = format_bytes(theoretical_bytes);
405    let worst_bucket_pct = worst_bucket_pct(b);
406    let upper_bound = 1u32 << b.depth.saturating_sub(b.bucket_depth);
407    let worst_bucket_raw = format!("{}/{}", b.utilization, upper_bound);
408    let ttl = format_ttl_seconds(b.batch_ttl);
409
410    let (status, why) = if !b.usable {
411        (
412            StampStatus::Pending,
413            Some("waiting on chain confirmation (~10 blocks).".into()),
414        )
415    } else if b.batch_ttl <= 0 {
416        (
417            StampStatus::Expired,
418            Some("paid balance exhausted; topup or stop using.".into()),
419        )
420    } else if worst_bucket_pct >= 95 {
421        (
422            StampStatus::Critical,
423            Some(if b.immutable {
424                "immutable batch will REJECT next upload at this bucket.".into()
425            } else {
426                "mutable batch will silently overwrite oldest chunks.".into()
427            }),
428        )
429    } else if b.batch_ttl <= TOPUP_URGENT_SECS {
430        // Bucket headroom OK, but TTL is dangerously low. This
431        // arm comes before the worst-bucket-skewed check because
432        // a near-expiry batch with a fine bucket is still going
433        // to fail — the bucket prediction is moot.
434        (
435            StampStatus::Critical,
436            Some(format!(
437                "topup URGENT — TTL {} (under {}h threshold).",
438                ttl,
439                TOPUP_URGENT_SECS / 3600
440            )),
441        )
442    } else if worst_bucket_pct >= 80 {
443        (
444            StampStatus::Skewed,
445            Some(format!(
446                "worst bucket {worst_bucket_pct}% > safe headroom — dilute or stop using."
447            )),
448        )
449    } else if b.batch_ttl <= TOPUP_SOON_SECS {
450        // Healthy buckets but TTL low enough to plan ahead. Don't
451        // escalate to Skewed (operator might mis-read it as bucket
452        // skew); use a milder Skewed-for-time framing.
453        (
454            StampStatus::Skewed,
455            Some(format!(
456                "topup soon — TTL {} (under {}d planning threshold).",
457                ttl,
458                TOPUP_SOON_SECS / 86_400
459            )),
460        )
461    } else {
462        (StampStatus::Healthy, None)
463    };
464
465    StampRow {
466        label,
467        batch_id_short,
468        volume,
469        worst_bucket_pct,
470        worst_bucket_raw,
471        ttl,
472        ttl_seconds: b.batch_ttl,
473        immutable: b.immutable,
474        status,
475        why,
476    }
477}
478
479/// `MaxBucketCount` (Bee's `utilization`) as a 0..=100 percentage of
480/// the per-bucket upper bound `2^(depth - bucket_depth)`.
481fn worst_bucket_pct(b: &PostageBatch) -> u32 {
482    let upper_bound: u32 = 1u32 << b.depth.saturating_sub(b.bucket_depth);
483    if upper_bound == 0 {
484        0
485    } else {
486        let pct = (u64::from(b.utilization) * 100) / u64::from(upper_bound);
487        pct.min(100) as u32
488    }
489}
490
491/// Predicted economics for a batch. Replicates the canonical
492/// formula used across the ecosystem: `bzz_paid = amount × 2^depth
493/// / 1e16` (swarm-cli `stamp/buy.ts:97-103`, beekeeper-stamper
494/// `pkg/stamper/node.go:33-43`, gateway-proxy `stamps.ts:198-234`).
495///
496/// Returns `None` when Bee did not supply `amount` (rare; usually
497/// for not-yet-confirmed batches). f64 conversion is precise enough:
498/// realistic batches yield ≤ 10³ BZZ which leaves > 12 decimal
499/// digits of headroom in f64.
500fn compute_stamp_economics(b: &PostageBatch) -> Option<StampEconomics> {
501    let amount = b.amount.as_ref()?;
502    let two_pow_depth: num_bigint::BigInt = num_bigint::BigInt::from(1u32) << b.depth as usize;
503    let total_plur = amount * &two_pow_depth;
504    let bzz: f64 = total_plur.to_string().parse::<f64>().ok()? / 1e16;
505
506    let cap_bytes: u128 = (1u128 << b.depth) * 4096;
507    let volume_humanised = format_bytes(cap_bytes);
508
509    const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
510    let gib = cap_bytes as f64 / GIB;
511    let bzz_per_gib = if gib > 0.0 {
512        format!("{:.4} BZZ/GiB", bzz / gib)
513    } else {
514        "n/a".to_string()
515    };
516
517    Some(StampEconomics {
518        bzz_paid: format!("{bzz:.4} BZZ"),
519        volume_humanised,
520        bzz_per_gib,
521    })
522}
523
524/// Bytes → IEC binary (KiB / MiB / GiB / TiB).
525pub(crate) fn format_bytes(bytes: u128) -> String {
526    const K: u128 = 1024;
527    const M: u128 = K * 1024;
528    const G: u128 = M * 1024;
529    const T: u128 = G * 1024;
530    if bytes >= T {
531        format!("{:.1} TiB", bytes as f64 / T as f64)
532    } else if bytes >= G {
533        format!("{:.1} GiB", bytes as f64 / G as f64)
534    } else if bytes >= M {
535        format!("{:.1} MiB", bytes as f64 / M as f64)
536    } else if bytes >= K {
537        format!("{:.1} KiB", bytes as f64 / K as f64)
538    } else {
539        format!("{bytes} B")
540    }
541}
542
543pub(crate) fn format_ttl_seconds(secs: i64) -> String {
544    if secs <= 0 {
545        return "expired".into();
546    }
547    let days = secs / 86_400;
548    let hours = (secs % 86_400) / 3_600;
549    if days >= 1 {
550        format!("{days}d {hours:>2}h")
551    } else {
552        let minutes = (secs % 3_600) / 60;
553        format!("{hours}h {minutes:>2}m")
554    }
555}
556
557/// 8-character ASCII fill bar.
558fn fill_bar(pct: u32, width: usize) -> String {
559    let filled = ((pct as usize) * width) / 100;
560    let mut bar = String::with_capacity(width);
561    for _ in 0..filled.min(width) {
562        bar.push('▇');
563    }
564    for _ in filled.min(width)..width {
565        bar.push('░');
566    }
567    bar
568}
569
570impl Component for Stamps {
571    fn update(&mut self, action: Action) -> Result<Option<Action>> {
572        if matches!(action, Action::Tick) {
573            self.pull_latest();
574            self.drain_fetches();
575        }
576        Ok(None)
577    }
578
579    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
580        // Drill mode swallows Esc to dismiss; otherwise keys behave
581        // the same as in list mode (so `j`/`k` etc. don't surprise
582        // the operator after pressing Enter).
583        if matches!(
584            self.drill,
585            DrillState::Loaded { .. } | DrillState::Loading { .. } | DrillState::Failed { .. }
586        ) && matches!(key.code, KeyCode::Esc)
587        {
588            self.drill = DrillState::Idle;
589            return Ok(None);
590        }
591        match key.code {
592            KeyCode::Char('j') | KeyCode::Down => {
593                let n = self.snapshot.batches.len();
594                if n > 0 && self.selected + 1 < n {
595                    self.selected += 1;
596                }
597            }
598            KeyCode::Char('k') | KeyCode::Up => {
599                self.selected = self.selected.saturating_sub(1);
600            }
601            KeyCode::Enter => {
602                self.maybe_start_drill();
603            }
604            _ => {}
605        }
606        Ok(None)
607    }
608
609    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
610        let chunks = Layout::vertical([
611            Constraint::Length(3), // header
612            Constraint::Min(0),    // body (table or drill)
613            Constraint::Length(1), // selected detail (full IDs)
614            Constraint::Length(1), // footer
615        ])
616        .split(area);
617
618        // Header
619        let t = theme::active();
620        let count = self.snapshot.batches.len();
621        let mut header_l1 = vec![
622            Span::styled("STAMPS", Style::default().add_modifier(Modifier::BOLD)),
623            Span::raw(format!("  {count} batch(es)")),
624        ];
625        if let DrillState::Loaded { batch_id, .. }
626        | DrillState::Loading { batch_id }
627        | DrillState::Failed { batch_id, .. } = &self.drill
628        {
629            // Render the full hex so operators can click-drag to copy
630            // for block-explorer / Discord / support-thread use. The
631            // batch ID is 64 chars — fits cleanly on the second line.
632            let hex = batch_id.to_hex();
633            header_l1.push(Span::raw("   · drill "));
634            header_l1.push(Span::styled(hex, Style::default().fg(t.info)));
635        }
636        let header_l1 = Line::from(header_l1);
637        let mut header_l2 = Vec::new();
638        if let Some(err) = &self.snapshot.last_error {
639            let (color, msg) = theme::classify_header_error(err);
640            header_l2.push(Span::styled(msg, Style::default().fg(color)));
641        } else if !self.snapshot.is_loaded() {
642            header_l2.push(Span::styled(
643                format!("{} loading…", theme::spinner_glyph()),
644                Style::default().fg(t.dim),
645            ));
646        }
647        frame.render_widget(
648            Paragraph::new(vec![header_l1, Line::from(header_l2)])
649                .block(Block::default().borders(Borders::BOTTOM)),
650            chunks[0],
651        );
652
653        // Body
654        match &self.drill {
655            DrillState::Idle => self.draw_table(frame, chunks[1]),
656            DrillState::Loading { .. } => {
657                let msg = Line::from(Span::styled(
658                    "  fetching /stamps/<id>/buckets…  (Esc cancel)",
659                    Style::default().fg(t.dim),
660                ));
661                frame.render_widget(Paragraph::new(msg), chunks[1]);
662            }
663            DrillState::Failed { error, .. } => {
664                let msg = Line::from(vec![
665                    Span::raw("  drill failed: "),
666                    Span::styled(error.clone(), Style::default().fg(t.fail)),
667                    Span::raw("    (Esc to dismiss)"),
668                ]);
669                frame.render_widget(Paragraph::new(msg), chunks[1]);
670            }
671            DrillState::Loaded { view, .. } => self.draw_drill(frame, chunks[1], view),
672        }
673
674        // Selected detail — full batch ID + label of the highlighted
675        // row, rendered as a separate line so operators can click-
676        // drag to copy without entering drill mode. Only meaningful
677        // in table view; suppressed during drill since the drill
678        // header already prints the full ID.
679        if matches!(self.drill, DrillState::Idle) && !self.snapshot.batches.is_empty() {
680            let i = self.selected.min(self.snapshot.batches.len() - 1);
681            let b = &self.snapshot.batches[i];
682            let label = if b.label.is_empty() {
683                "(unlabeled)".to_string()
684            } else {
685                b.label.clone()
686            };
687            let detail = Line::from(vec![
688                Span::styled("  selected: ", Style::default().fg(t.dim)),
689                Span::styled(b.batch_id.to_hex(), Style::default().fg(t.info)),
690                Span::raw("  "),
691                Span::styled(label, Style::default().fg(t.dim)),
692            ]);
693            frame.render_widget(Paragraph::new(detail), chunks[2]);
694        }
695
696        // Footer — keymap shifts in drill mode.
697        let footer = match &self.drill {
698            DrillState::Idle => Line::from(vec![
699                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
700                Span::raw(" switch screen  "),
701                Span::styled(
702                    " ↑↓/jk ",
703                    Style::default().fg(Color::Black).bg(Color::White),
704                ),
705                Span::raw(" select  "),
706                Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
707                Span::raw(" drill  "),
708                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
709                Span::raw(" help  "),
710                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
711                Span::raw(" quit  "),
712                Span::styled(" I/M ", Style::default().fg(t.dim)),
713                Span::raw(" immutable / mutable "),
714            ]),
715            _ => Line::from(vec![
716                Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
717                Span::raw(" close drill  "),
718                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
719                Span::raw(" switch screen  "),
720                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
721                Span::raw(" help  "),
722                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
723                Span::raw(" quit "),
724            ]),
725        };
726        frame.render_widget(Paragraph::new(footer), chunks[3]);
727
728        Ok(())
729    }
730}
731
732impl Stamps {
733    fn draw_table(&mut self, frame: &mut Frame, area: Rect) {
734        use ratatui::layout::{Constraint, Layout};
735
736        let t = theme::active();
737
738        // Pinned column header + scrollable body, same pattern as
739        // S6. Header doesn't scroll out from under the cursor.
740        let table_chunks =
741            Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
742        frame.render_widget(
743            Paragraph::new(Line::from(Span::styled(
744                "   LABEL                BATCH        VOLUME      WORST BUCKET                TTL         STATUS",
745                Style::default()
746                    .fg(t.dim)
747                    .add_modifier(Modifier::BOLD),
748            ))),
749            table_chunks[0],
750        );
751
752        if self.snapshot.batches.is_empty() {
753            frame.render_widget(
754                Paragraph::new(Line::from(Span::styled(
755                    "   (no batches yet — buy one with swarm-cli or `bee stamps buy`)",
756                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
757                ))),
758                table_chunks[1],
759            );
760            return;
761        }
762
763        let mut lines: Vec<Line> = Vec::new();
764        // Map batch index → visual line index where that row's main
765        // line starts. Used by `clamp_scroll` to translate a row
766        // selection into a visual-line offset, since rows may emit
767        // 1 or 2 lines depending on whether they have a `why`
768        // continuation.
769        let mut row_starts: Vec<usize> = Vec::new();
770        for (i, r) in Self::rows_for(&self.snapshot).into_iter().enumerate() {
771            row_starts.push(lines.len());
772            let bar = fill_bar(r.worst_bucket_pct, 8);
773            let immut_glyph = if r.immutable { "I" } else { "M" };
774            let cursor = if i == self.selected {
775                format!("{} ", t.glyphs.cursor)
776            } else {
777                "  ".to_string()
778            };
779            lines.push(Line::from(vec![
780                Span::styled(
781                    cursor,
782                    Style::default()
783                        .fg(if i == self.selected { t.accent } else { t.dim })
784                        .add_modifier(Modifier::BOLD),
785                ),
786                Span::styled(
787                    format!("{:<20}", truncate(&r.label, 20)),
788                    Style::default().add_modifier(Modifier::BOLD),
789                ),
790                Span::raw(format!("{:<13}", r.batch_id_short)),
791                Span::raw(format!("{:<12}", r.volume)),
792                Span::styled(
793                    format!("{bar} {:>3}% ({})", r.worst_bucket_pct, r.worst_bucket_raw),
794                    Style::default().fg(bucket_color(r.worst_bucket_pct)),
795                ),
796                Span::raw("    "),
797                Span::raw(format!("{:<10} ", r.ttl)),
798                Span::styled(immut_glyph, Style::default().fg(t.dim)),
799                Span::raw(" "),
800                Span::styled(
801                    r.status.label(),
802                    Style::default()
803                        .fg(r.status.color())
804                        .add_modifier(Modifier::BOLD),
805                ),
806            ]));
807            if let Some(why) = r.why {
808                lines.push(Line::from(vec![
809                    Span::raw(format!("        {} ", t.glyphs.continuation)),
810                    Span::styled(
811                        why,
812                        Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
813                    ),
814                ]));
815            }
816        }
817
818        // Translate the row cursor → visual-line cursor. Use the
819        // first line of the selected row as the "selected visual
820        // line" so clamp_scroll keeps that row's main line on
821        // screen (the continuation tooltip will follow if it fits).
822        let visual_cursor = row_starts.get(self.selected).copied().unwrap_or(0);
823        let body = table_chunks[1];
824        let visible_rows = body.height as usize;
825        self.scroll_offset = super::scroll::clamp_scroll(
826            visual_cursor,
827            self.scroll_offset,
828            visible_rows,
829            lines.len(),
830        );
831        frame.render_widget(
832            Paragraph::new(lines.clone()).scroll((self.scroll_offset as u16, 0)),
833            body,
834        );
835        super::scroll::render_scrollbar(frame, body, self.scroll_offset, visible_rows, lines.len());
836    }
837
838    fn draw_drill(&self, frame: &mut Frame, area: Rect, view: &StampDrillView) {
839        let t = theme::active();
840        let mut lines: Vec<Line> = Vec::new();
841        // Headline summary line.
842        let total_buckets: u32 = view.fill_distribution.iter().sum();
843        lines.push(Line::from(vec![
844            Span::raw("  depth "),
845            Span::styled(
846                format!("{}", view.depth),
847                Style::default().add_modifier(Modifier::BOLD),
848            ),
849            Span::raw("   bucket-depth "),
850            Span::styled(
851                format!("{}", view.bucket_depth),
852                Style::default().add_modifier(Modifier::BOLD),
853            ),
854            Span::raw("   per-bucket cap "),
855            Span::styled(
856                format!("{}", view.upper_bound),
857                Style::default().add_modifier(Modifier::BOLD),
858            ),
859            Span::raw("   "),
860            Span::styled(
861                format!("{} buckets", total_buckets),
862                Style::default().fg(t.dim),
863            ),
864        ]));
865        lines.push(Line::from(vec![
866            Span::raw("  total chunks "),
867            Span::styled(
868                format!("{}", view.total_chunks),
869                Style::default().add_modifier(Modifier::BOLD),
870            ),
871            Span::raw(" / "),
872            Span::styled(
873                format!("{}", view.theoretical_capacity),
874                Style::default().fg(t.dim),
875            ),
876            Span::raw("   worst bucket "),
877            Span::styled(
878                format!("{}%", view.worst_pct),
879                Style::default()
880                    .fg(bucket_color(view.worst_pct))
881                    .add_modifier(Modifier::BOLD),
882            ),
883        ]));
884        if let Some(e) = &view.economics {
885            // Predictive economics line — same `amount × 2^depth /
886            // 1e16` formula as swarm-cli/beekeeper-stamper. Lets
887            // operators sanity-check what they paid for this batch
888            // alongside the bucket telemetry.
889            lines.push(Line::from(vec![
890                Span::raw("  paid "),
891                Span::styled(
892                    e.bzz_paid.clone(),
893                    Style::default().add_modifier(Modifier::BOLD),
894                ),
895                Span::raw("   volume "),
896                Span::styled(
897                    e.volume_humanised.clone(),
898                    Style::default().add_modifier(Modifier::BOLD),
899                ),
900                Span::raw("   "),
901                Span::styled(e.bzz_per_gib.clone(), Style::default().fg(t.dim)),
902            ]));
903        }
904        lines.push(Line::from(""));
905
906        // Fill-distribution histogram.
907        lines.push(Line::from(Span::styled(
908            "  FILL %       COUNT   DISTRIBUTION",
909            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
910        )));
911        let max_bin = view
912            .fill_distribution
913            .iter()
914            .copied()
915            .max()
916            .unwrap_or(1)
917            .max(1);
918        for (idx, count) in view.fill_distribution.iter().enumerate() {
919            let label = FILL_BIN_LABELS[idx];
920            let bar_width = ((u64::from(*count) * 30) / u64::from(max_bin)) as usize;
921            let bar: String = std::iter::repeat_n('▇', bar_width).collect();
922            // Bin colour follows the fill range so the operator's eye
923            // jumps to the rows that matter (red 100 %, yellow 80–99,
924            // pass otherwise).
925            let bin_color = match idx {
926                5 => t.fail,
927                4 => t.warn,
928                _ => t.pass,
929            };
930            lines.push(Line::from(vec![
931                Span::raw("  "),
932                Span::raw(format!("{label:<10}  ")),
933                Span::styled(
934                    format!("{count:>5}   "),
935                    Style::default().add_modifier(Modifier::BOLD),
936                ),
937                Span::styled(bar, Style::default().fg(bin_color)),
938            ]));
939        }
940        lines.push(Line::from(""));
941
942        // Top-N worst buckets.
943        if !view.worst_buckets.is_empty() {
944            lines.push(Line::from(Span::styled(
945                "  WORST BUCKETS",
946                Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
947            )));
948            for w in &view.worst_buckets {
949                if w.collisions == 0 {
950                    // Once we hit zero-collision buckets the rest are
951                    // also zero — don't pad the worst-N with junk.
952                    break;
953                }
954                lines.push(Line::from(vec![
955                    Span::raw("  "),
956                    Span::raw(format!("#{:<8}", w.bucket_id)),
957                    Span::raw(format!("{:>4} / {}    ", w.collisions, view.upper_bound)),
958                    Span::styled(
959                        format!("{}%", w.pct),
960                        Style::default()
961                            .fg(bucket_color(w.pct))
962                            .add_modifier(Modifier::BOLD),
963                    ),
964                ]));
965            }
966        }
967
968        frame.render_widget(Paragraph::new(lines), area);
969    }
970}
971
972fn truncate(s: &str, max: usize) -> String {
973    if s.chars().count() <= max {
974        s.to_string()
975    } else {
976        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
977        out.push('…');
978        out
979    }
980}
981
982fn bucket_color(pct: u32) -> Color {
983    let t = theme::active();
984    if pct >= 95 {
985        t.fail
986    } else if pct >= 80 {
987        t.warn
988    } else {
989        t.pass
990    }
991}
992
993#[cfg(test)]
994mod tests {
995    use super::*;
996
997    fn buckets_with(counts: &[(u32, u32)], depth: u8, bucket_depth: u8) -> PostageBatchBuckets {
998        let upper_bound = 1u32 << (depth - bucket_depth);
999        let buckets = counts
1000            .iter()
1001            .map(|(id, c)| BatchBucket {
1002                bucket_id: *id,
1003                collisions: *c,
1004            })
1005            .collect();
1006        PostageBatchBuckets {
1007            depth,
1008            bucket_depth,
1009            bucket_upper_bound: upper_bound,
1010            buckets,
1011        }
1012    }
1013
1014    #[test]
1015    fn fill_bar_clamps_to_width() {
1016        assert_eq!(fill_bar(0, 8), "░░░░░░░░");
1017        assert_eq!(fill_bar(50, 8), "▇▇▇▇░░░░");
1018        assert_eq!(fill_bar(100, 8), "▇▇▇▇▇▇▇▇");
1019        assert_eq!(fill_bar(150, 8), "▇▇▇▇▇▇▇▇"); // saturating
1020    }
1021
1022    #[test]
1023    fn format_bytes_iec() {
1024        assert_eq!(format_bytes(0), "0 B");
1025        assert_eq!(format_bytes(1024), "1.0 KiB");
1026        assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
1027        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GiB");
1028        assert_eq!(format_bytes(16 * 1024 * 1024 * 1024), "16.0 GiB");
1029    }
1030
1031    #[test]
1032    fn format_ttl_zero_is_expired() {
1033        assert_eq!(format_ttl_seconds(0), "expired");
1034        assert_eq!(format_ttl_seconds(-5), "expired");
1035    }
1036
1037    #[test]
1038    fn format_ttl_days_and_hours() {
1039        // 47d 12h
1040        assert_eq!(format_ttl_seconds(47 * 86_400 + 12 * 3_600), "47d 12h");
1041    }
1042
1043    #[test]
1044    fn format_ttl_under_a_day_uses_hours_minutes() {
1045        assert_eq!(format_ttl_seconds(2 * 3_600 + 30 * 60), "2h 30m");
1046    }
1047
1048    #[test]
1049    fn drill_view_bins_and_worst_n() {
1050        // depth=22, bucket_depth=16 → upper_bound=64. Construct a
1051        // small sample with one full bucket, one near-full, two
1052        // mid, rest empty.
1053        let buckets = buckets_with(
1054            &[
1055                (0, 64), // 100 %  → bin 5
1056                (1, 60), // 93 %   → bin 4
1057                (2, 40), // 62 %   → bin 3
1058                (3, 20), // 31 %   → bin 2
1059                (4, 1),  // 1 %    → bin 1
1060                (5, 0),  // 0 %    → bin 0
1061            ],
1062            22,
1063            16,
1064        );
1065        let view = Stamps::compute_drill_view(&buckets, None);
1066        assert_eq!(view.depth, 22);
1067        assert_eq!(view.bucket_depth, 16);
1068        assert_eq!(view.upper_bound, 64);
1069        assert_eq!(view.total_chunks, 64 + 60 + 40 + 20 + 1);
1070        assert_eq!(view.fill_distribution, [1, 1, 1, 1, 1, 1]);
1071        assert_eq!(view.worst_pct, 100);
1072        // Worst-N is the worst-3 here (rest are zero — truncated).
1073        // The order is: bucket 0 (64), bucket 1 (60), bucket 2 (40),
1074        // then 20, 1; the trailing zero-bucket is included only up
1075        // to 10 entries — we filter zero-collisions in the renderer
1076        // but compute_drill_view includes them up to the cap.
1077        assert_eq!(view.worst_buckets.len(), 6);
1078        assert_eq!(view.worst_buckets[0].bucket_id, 0);
1079        assert_eq!(view.worst_buckets[0].pct, 100);
1080        assert_eq!(view.worst_buckets[1].bucket_id, 1);
1081        assert_eq!(view.worst_buckets[1].pct, 93);
1082    }
1083
1084    #[test]
1085    fn drill_view_handles_empty_buckets() {
1086        let buckets = buckets_with(&[], 22, 16);
1087        let view = Stamps::compute_drill_view(&buckets, None);
1088        assert_eq!(view.total_chunks, 0);
1089        assert_eq!(view.fill_distribution, [0; 6]);
1090        assert_eq!(view.worst_pct, 0);
1091        assert!(view.worst_buckets.is_empty());
1092    }
1093
1094    #[test]
1095    fn drill_view_caps_worst_at_ten() {
1096        // 12 distinct buckets, all collisions=1 — top-10 should
1097        // truncate at 10 entries.
1098        let entries: Vec<(u32, u32)> = (0..12).map(|i| (i, 1)).collect();
1099        let buckets = buckets_with(&entries, 22, 16);
1100        let view = Stamps::compute_drill_view(&buckets, None);
1101        assert_eq!(view.worst_buckets.len(), 10);
1102    }
1103
1104    #[test]
1105    fn drill_view_breaks_ties_by_bucket_id() {
1106        let buckets = buckets_with(&[(7, 5), (3, 5), (10, 5)], 22, 16);
1107        let view = Stamps::compute_drill_view(&buckets, None);
1108        // All three tie on collisions=5 → ascending by bucket_id.
1109        assert_eq!(
1110            view.worst_buckets
1111                .iter()
1112                .map(|w| w.bucket_id)
1113                .collect::<Vec<_>>(),
1114            vec![3, 7, 10],
1115        );
1116    }
1117
1118    #[test]
1119    fn fill_bin_handles_overflow_collisions() {
1120        // Bee occasionally over-reports collisions vs upper_bound.
1121        // Treat it as the saturated 100 % bin rather than panicking.
1122        assert_eq!(bucket_fill_bin(70, 64), 5);
1123    }
1124
1125    fn make_batch(amount: Option<num_bigint::BigInt>, depth: u8) -> PostageBatch {
1126        PostageBatch {
1127            batch_id: bee::swarm::BatchId::new(&[0u8; 32]).unwrap(),
1128            amount,
1129            start: 0,
1130            owner: String::new(),
1131            depth,
1132            bucket_depth: depth.saturating_sub(6),
1133            immutable: true,
1134            batch_ttl: 30 * 86_400,
1135            utilization: 0,
1136            usable: true,
1137            exists: true,
1138            label: "test".into(),
1139            block_number: 0,
1140        }
1141    }
1142
1143    #[test]
1144    fn economics_returns_none_when_amount_missing() {
1145        let b = make_batch(None, 22);
1146        assert!(compute_stamp_economics(&b).is_none());
1147    }
1148
1149    #[test]
1150    fn economics_typical_batch_formats_strings() {
1151        // amount=1e14 PLUR/chunk, depth=22 → 2^22 = 4_194_304 chunks
1152        // total = 1e14 × 4_194_304 = 4.194304e20 PLUR
1153        // bzz_paid = 4.194304e20 / 1e16 = 41943.04 BZZ
1154        // capacity = 4_194_304 × 4096 = 16 GiB exactly
1155        // bzz_per_gib = 41943.04 / 16 = 2621.44 BZZ/GiB
1156        let amount = num_bigint::BigInt::from(100_000_000_000_000u64);
1157        let b = make_batch(Some(amount), 22);
1158        let e = compute_stamp_economics(&b).expect("amount present");
1159        assert_eq!(e.bzz_paid, "41943.0400 BZZ");
1160        assert_eq!(e.volume_humanised, "16.0 GiB");
1161        assert_eq!(e.bzz_per_gib, "2621.4400 BZZ/GiB");
1162    }
1163
1164    #[test]
1165    fn economics_wired_through_compute_drill_view() {
1166        let amount = num_bigint::BigInt::from(100_000_000_000_000u64);
1167        let batch = make_batch(Some(amount), 22);
1168        let buckets = buckets_with(&[(0, 1)], 22, 16);
1169        let view = Stamps::compute_drill_view(&buckets, Some(&batch));
1170        let e = view.economics.as_ref().expect("economics populated");
1171        assert_eq!(e.volume_humanised, "16.0 GiB");
1172    }
1173
1174    #[test]
1175    fn row_topup_urgent_when_ttl_under_24h_with_healthy_buckets() {
1176        // Bucket headroom is fine but TTL has dropped below 24h —
1177        // the bucket-skewed arm shouldn't fire; the urgent arm wins.
1178        let mut b = make_batch(None, 22);
1179        b.batch_ttl = 12 * 3_600;
1180        b.utilization = 14; // ~22 %, healthy
1181        let row = row_from_batch(&b);
1182        assert_eq!(row.status, StampStatus::Critical);
1183        assert!(row.why.as_ref().unwrap().contains("topup URGENT"));
1184    }
1185
1186    #[test]
1187    fn row_topup_soon_when_ttl_under_7d_with_healthy_buckets() {
1188        // 3 days left, buckets fine. Should be Skewed with the
1189        // planning-threshold tooltip.
1190        let mut b = make_batch(None, 22);
1191        b.batch_ttl = 3 * 86_400;
1192        b.utilization = 14;
1193        let row = row_from_batch(&b);
1194        assert_eq!(row.status, StampStatus::Skewed);
1195        assert!(row.why.as_ref().unwrap().contains("topup soon"));
1196    }
1197
1198    #[test]
1199    fn row_critical_bucket_wins_over_urgent_ttl() {
1200        // Worst bucket already at 95 %+ AND TTL is under 24h. The
1201        // bucket-driven critical message takes precedence (operators
1202        // usually act on bucket fill before TTL anyway).
1203        let mut b = make_batch(None, 22);
1204        b.batch_ttl = 12 * 3_600;
1205        b.utilization = 63; // 98 %
1206        let row = row_from_batch(&b);
1207        assert_eq!(row.status, StampStatus::Critical);
1208        let why = row.why.as_ref().unwrap();
1209        assert!(!why.contains("topup URGENT"));
1210        assert!(why.contains("REJECT") || why.contains("overwrite"));
1211    }
1212}