1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum StampStatus {
33 Pending,
35 Expired,
37 Critical,
40 Skewed,
42 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#[derive(Debug, Clone)]
69pub struct StampRow {
70 pub label: String,
71 pub batch_id_short: String,
72 pub volume: String,
76 pub worst_bucket_pct: u32,
79 pub worst_bucket_raw: String,
81 pub ttl: String,
83 pub immutable: bool,
86 pub status: StampStatus,
87 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 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
175fn 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
187fn 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
220fn 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), Constraint::Min(0), Constraint::Length(1), ])
247 .split(area);
248
249 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 let mut lines: Vec<Line> = Vec::new();
275 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 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), "▇▇▇▇▇▇▇▇"); }
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 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}