1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum StampStatus {
47 Pending,
49 Expired,
51 Critical,
54 Skewed,
56 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#[derive(Debug, Clone)]
84pub struct StampRow {
85 pub label: String,
86 pub batch_id_short: String,
87 pub volume: String,
91 pub worst_bucket_pct: u32,
94 pub worst_bucket_raw: String,
96 pub ttl: String,
98 pub ttl_seconds: i64,
102 pub immutable: bool,
105 pub status: StampStatus,
106 pub why: Option<String>,
108}
109
110pub const TOPUP_SOON_SECS: i64 = 7 * 24 * 3600;
117pub const TOPUP_URGENT_SECS: i64 = 24 * 3600;
118
119pub const FILL_BIN_LABELS: &[&str] = &[
123 "0 %",
124 "1 – 19 %",
125 "20 – 49 %",
126 "50 – 79 %",
127 "80 – 99 %",
128 "100 %",
129];
130
131#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct StampDrillView {
135 pub depth: u8,
136 pub bucket_depth: u8,
137 pub upper_bound: u32,
138 pub total_chunks: u64,
141 pub theoretical_capacity: u128,
145 pub fill_distribution: [u32; 6],
149 pub worst_buckets: Vec<WorstBucket>,
152 pub worst_pct: u32,
155 pub economics: Option<StampEconomics>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct StampEconomics {
169 pub bzz_paid: String,
172 pub volume_humanised: String,
176 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#[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 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 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 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, }
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 pub fn rows_for(snap: &StampsSnapshot) -> Vec<StampRow> {
281 snap.batches.iter().map(row_from_batch).collect()
282 }
283
284 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 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 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; }
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; }
372 let pct = pct_of(collisions, upper_bound);
373 match pct {
374 0 => 0, 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 (
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 (
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
479fn 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
491fn 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
524pub(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
557fn 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 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), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ])
616 .split(area);
617
618 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 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 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 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 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 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 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 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 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 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 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 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 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 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), "▇▇▇▇▇▇▇▇"); }
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 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 let buckets = buckets_with(
1054 &[
1055 (0, 64), (1, 60), (2, 40), (3, 20), (4, 1), (5, 0), ],
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 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 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 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 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 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 let mut b = make_batch(None, 22);
1179 b.batch_ttl = 12 * 3_600;
1180 b.utilization = 14; 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 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 let mut b = make_batch(None, 22);
1204 b.batch_ttl = 12 * 3_600;
1205 b.utilization = 63; 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}