//! Progress reporting for pipeline runs: a full-screen dashboard when //! stdout is a terminal, or line-oriented output when it is not (or when //! `--plain` asks for it). //! //! Both reporters consume the pipeline's event channel until it //! disconnects (which happens exactly when the pipeline finishes) and //! let the user request suspension — with a key in the dashboard, or //! SIGINT/SIGTERM in either mode. //! //! The dashboard answers three questions at once: what the run has //! accomplished (the totals the pipeline itself keeps), what its workers //! are doing this instant (the stages table), and what is queued ahead //! of them (the table's waiting column for how deep each queue is now, //! the backlog pane for which way it is going, and the pending pane for //! the paths a run has yet to visit). Completed //! items scroll past beside those, and skips — which are reports rather //! than problems, and can be very numerous — are reduced to the most //! recent one alongside their count. use std::{ collections::{HashMap, VecDeque}, io::Write as _, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, time::{Duration, Instant}, }; use crossbeam_channel::Receiver; use crossterm::event::{Event as TermEvent, KeyCode, KeyModifiers}; use ratatui::{ Frame, layout::{Constraint, Layout}, style::{Modifier, Style}, text::Line, widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, }; use beeping::pipeline::{EarlyFinish, Event, Session, SkipReason, Stage, StoreLimit}; /// History kept for the activity pane; the visible tail is cut to the /// pane's actual height at render time. const HISTORY_LINES: usize = 128; /// How much run time separates two backlog samples. The pipeline /// reports several times a second, which would fill the backlog pane /// with a few seconds of noise; a couple of seconds a column gives it /// several minutes, which is the span over which a queue meaningfully /// grows or drains. const BACKLOG_SAMPLE: Duration = Duration::from_secs(2); /// Backlog samples kept per stage, comfortably more than the widest /// pane, so that history survives a narrow window being widened. const BACKLOG_HISTORY: usize = 512; /// The width the activity log gives its verb, so that the paths beside /// it line up. const VERB_WIDTH: u16 = 8; /// How much recent history throughput is averaged over. Long enough to /// ride out one slow file, short enough to still mean "right now". const RATE_WINDOW: Duration = Duration::from_secs(5); /// The shortest span of samples that yields a throughput figure; below /// it the quotient is more noise than measurement. const RATE_MIN_SPAN: Duration = Duration::from_millis(500); /// One line of the activity log. enum Activity { /// Something that happened to a path. The verb and the path are /// kept apart rather than formatted when the event arrives, because /// how much room there is for the path is not known until the pane /// is drawn — and what must survive the cut is its tail. Item { verb: &'static str, path: String }, /// Something the run has to say about itself, which is prose rather /// than a path and is left whole. Note(String), } impl Activity { fn render(&self, width: u16) -> String { match self { Activity::Item { verb, path } => format!( "{verb: note.clone(), } } } /// One item in a worker's hands. struct InHand { /// When it was taken up, so the newest and the oldest can be told /// apart. sequence: u64, path: String, /// How far through it the worker has got, where the stage knows and /// the item is big enough for it to matter. progress: Option<(u64, u64)>, } impl InHand { /// The item as a table cell: how far through it the worker is, then /// as much of its path as the column can hold. The percentage keeps /// its full width — it is the part that changes, and the part that /// says whether anything is happening at all. fn describe(&self, width: u16) -> String { match self.progress { Some((done, total)) if total > 0 => { let percent = (done.saturating_mul(100) / total).min(100); let shown = format!("{percent:>3}% "); format!( "{shown}{}", fit_path(&self.path, width.saturating_sub(shown.len() as u16)) ) } _ => fit_path(&self.path, width), } } } /// What one stage of the pipeline is doing, and how much is queued for /// it. struct StageView { stage: Stage, /// How many workers the stage runs, or `None` for the stages whose /// work finalization does rather than a pool. threads: Option, /// The items in workers' hands, by worker index. busy: HashMap, waiting: u64, /// How deep the queue has been, oldest sample first: what the /// backlog pane draws. history: VecDeque, /// The head of the queue by name, in the order it will be taken up, /// or `None` for a stage whose backlog is not a queue of paths. upcoming: Option>, } impl StageView { fn new(stage: Stage) -> StageView { StageView { stage, threads: None, busy: HashMap::new(), waiting: 0, history: VecDeque::new(), upcoming: None, } } /// Takes one backlog sample, dropping the oldest once the history is /// longer than any pane could use. fn record(&mut self, waiting: u64) { self.history.push_back(waiting); if self.history.len() > BACKLOG_HISTORY { self.history.pop_front(); } } /// The item the stage took up most recently and still holds: where /// the run has got to. fn newest(&self) -> Option<&InHand> { self.busy.values().max_by_key(|item| item.sequence) } /// The item that has been in the stage's hands longest: the one /// holding the stage up, which the newest item hides whenever the /// other workers are churning past it. Named for what it measures — /// how long it has been held — rather than for the slowness that is /// usually, but not always, the reason. /// /// Both are drawn from the items actually in hand, so neither ever /// names something already finished, and a stage with one item names /// it in both columns. fn oldest(&self) -> Option<&InHand> { self.busy.values().min_by_key(|item| item.sequence) } /// The "busy" column: workers occupied out of workers available. fn occupancy(&self) -> String { match self.threads { Some(threads) => format!("{}/{}", self.busy.len(), threads), None => "—".to_string(), } } } /// A throughput estimate in bytes per second, over the last few seconds /// of totals samples. /// /// Both quantities are the pipeline's own — the time it has spent /// running and the bytes it has moved — so the estimate does not depend /// on how often the display redraws. #[derive(Default)] struct Rate { samples: VecDeque<(Duration, u64)>, } impl Rate { fn observe(&mut self, elapsed: Duration, bytes: u64) { // Samples only ever move forward within a run; anything else is // a fresh pipeline reporting into the same display if let Some((newest, _)) = self.samples.back() && elapsed < *newest { self.samples.clear(); } self.samples.push_back((elapsed, bytes)); // Keep the shortest run of samples that still spans the window while self.samples.len() > 2 && elapsed - self.samples[1].0 >= RATE_WINDOW { self.samples.pop_front(); } } fn per_second(&self) -> Option { let (oldest, then) = *self.samples.front()?; let (newest, now) = *self.samples.back()?; let span = newest - oldest; if span < RATE_MIN_SPAN { return None; } Some((now.saturating_sub(then) as f64 / span.as_secs_f64()) as u64) } } /// What the run is doing, as far as its events have said. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] enum Phase { #[default] Working, /// Storing a manifest for everything backed up so far, so that the /// suspended run can be restored from. Checkpointing, /// All content is stored; the snapshot is being built. Finalizing, } /// Everything the reporters display, fed by pipeline events. #[derive(Default)] struct Dashboard { /// The run's totals, as the pipeline last reported them. It keeps /// these counts for its own durable state, so taking them from it /// rather than re-deriving them here keeps one set of numbers. totals: Session, phase: Phase, recent: VecDeque, last_skip: Option, /// Stage views in the order the pipeline first mentioned them, which /// is the order work flows through them. stages: Vec, /// Throughput of finished content: the bytes of files stored or /// restored. content_rate: Rate, /// Throughput to and from the repository. Distinct from the content /// rate, and worth showing beside it: a run chewing through one huge /// file finishes no content while transferring steadily, and a /// re-backup of unchanged files does the reverse. transfer_rate: Rate, /// Bytes moved to or from the repository this session, accumulated /// as they are reported and sampled whenever new totals arrive. transferred: u64, /// Ticks up with every item picked up anywhere, so each stage can /// tell which of the items in its hands it took up last. sequence: u64, /// The pipeline's running time as of the last backlog sample, which /// is what paces the next one. backlog_sampled: Option, } impl Dashboard { fn apply(&mut self, event: Event) { match event { Event::Scanned { path } => self.push_recent(Activity::Item { verb: "scanned", path: path.display().to_string(), }), Event::Stored { path, .. } => self.push_recent(Activity::Item { verb: "stored", path: path.display().to_string(), }), Event::Restored { path, .. } => self.push_recent(Activity::Item { verb: "restored", path: path.display().to_string(), }), Event::Skipped { path, reason } => { self.last_skip = Some(format!("{} ({})", path.display(), describe_skip(&reason))); } Event::Checkpointing => self.phase = Phase::Checkpointing, Event::Working => self.phase = Phase::Working, Event::FinishingEarly(reason) => self.push_recent(Activity::Note(format!( "finishing early: {}", describe_early_finish(reason) ))), Event::LimitNotApplied(limit) => { self.push_recent(Activity::Note(describe_unapplied_limit(limit))) } Event::Finalizing => self.phase = Phase::Finalizing, Event::Resumed(session) => { self.observe(session); self.push_recent(Activity::Note( "resumed: totals include previous sessions".to_string(), )); } Event::Totals(session) => self.observe(session), Event::Staffing { stage, threads } => self.stage(stage).threads = Some(threads), Event::Began { stage, worker, path, } => { self.sequence += 1; let sequence = self.sequence; self.stage(stage).busy.insert( worker, InHand { sequence, path: path.display().to_string(), progress: None, }, ); } Event::Progressed { stage, worker, done, total, } => { if let Some(item) = self.stage(stage).busy.get_mut(&worker) { item.progress = Some((done, total)); } } Event::Idle { stage, worker } => { self.stage(stage).busy.remove(&worker); } Event::Backlog(stages) => { // Depths are taken as they arrive; history is sampled // against the pipeline's own running time, as the // throughput figures are, because the display is fed // far more often than the history needs a column. let elapsed = self.totals.elapsed; // Time that has gone backwards is a fresh pipeline // reporting into the same display, and its // predecessor's queues do not continue into this one if self.backlog_sampled.is_some_and(|last| elapsed < last) { self.backlog_sampled = None; for view in &mut self.stages { view.history.clear(); } } let sampling = self .backlog_sampled .is_none_or(|last| elapsed - last >= BACKLOG_SAMPLE); if sampling { self.backlog_sampled = Some(elapsed); } for backlog in stages { let view = self.stage(backlog.stage); view.waiting = backlog.waiting; view.upcoming = backlog.upcoming.map(|paths| { paths .iter() .map(|path| path.display().to_string()) .collect() }); if sampling { view.record(backlog.waiting); } } } Event::Transferred { bytes } => self.transferred += bytes, } } /// The view of one stage, created on first mention so the table /// grows in pipeline order without either reporter knowing which /// stages a given pipeline has. fn stage(&mut self, stage: Stage) -> &mut StageView { let position = self .stages .iter() .position(|view| view.stage == stage) .unwrap_or_else(|| { self.stages.push(StageView::new(stage)); self.stages.len() - 1 }); &mut self.stages[position] } /// Takes a fresh set of totals, and with them a sampling point for /// both throughput figures — the pipeline's own running time is the /// clock they are measured against. fn observe(&mut self, session: Session) { self.totals = session; self.content_rate.observe( session.elapsed, session.progress.stored_bytes + session.progress.restored_bytes, ); self.transfer_rate .observe(session.elapsed, self.transferred); } fn push_recent(&mut self, line: Activity) { self.recent.push_back(line); if self.recent.len() > HISTORY_LINES { self.recent.pop_front(); } } fn summary(&self) -> String { let progress = &self.totals.progress; let mut parts = Vec::new(); if progress.scanned > 0 { parts.push(format!("{} directories scanned", count(progress.scanned))); } if progress.stored_files > 0 { parts.push(format!( "{} files stored ({})", count(progress.stored_files), human_bytes(progress.stored_bytes) )); } if progress.restored_files > 0 { parts.push(format!( "{} files restored ({})", count(progress.restored_files), human_bytes(progress.restored_bytes) )); } if progress.skipped > 0 { parts.push(format!("{} skipped", count(progress.skipped))); } if parts.is_empty() { parts.push("no activity".to_string()); } parts.join(", ") } /// What the run is doing right now, as one line: its state, the time /// it has spent working (across every session, not just this one), /// and its recent throughput. fn status(&self, suspending: bool) -> String { // A checkpoint happens *because* the run is suspending, and is // the more informative of the two things to say: it can take a // while, and it is what makes the interrupted backup restorable let state = match (self.phase, suspending) { (Phase::Checkpointing, _) => "checkpointing — making what is stored so far restorable", (Phase::Finalizing, _) => "finalizing", (Phase::Working, true) => "suspending — letting workers finish their current items", (Phase::Working, false) => "running", }; let mut parts = vec![state.to_string()]; if self.totals.elapsed >= Duration::from_secs(1) { parts.push(format!("elapsed {}", human_duration(self.totals.elapsed))); } if let Some(rate) = self.content_rate.per_second() { parts.push(format!("content {}/s", human_bytes(rate))); } if let Some(rate) = self.transfer_rate.per_second() { parts.push(format!("repository {}/s", human_bytes(rate))); } parts.join(" · ") } /// The skip line: how many were skipped, and the most recent one. /// Only the latest is worth screen space — they are reports, not /// problems, and a long run produces a great many of them. /// /// A resumed run counts the skips of earlier sessions without having /// a recent one to name, so the two parts are reported separately. fn skip_line(&self) -> String { let skipped = self.totals.progress.skipped; match (&self.last_skip, skipped) { (Some(skip), _) => format!("skipped {} · last: {skip}", count(skipped)), (None, 0) => "skipped none".to_string(), (None, skipped) => format!("skipped {}", count(skipped)), } } /// The stages table flattened onto one line, for the line-oriented /// reporter: the same occupancy, backlog, and current item the /// dashboard shows in columns. fn stage_line(&self) -> Option { if self.stages.is_empty() { return None; } let parts: Vec = self .stages .iter() .map(|view| { let mut part = match view.threads { Some(threads) => format!( "{} {}/{} busy, {} waiting", view.stage.label(), view.busy.len(), threads, count(view.waiting) ), None => format!("{} {} waiting", view.stage.label(), count(view.waiting)), }; // The two named items, collapsed to one when a stage // holds a single item and they are the same match (view.newest(), view.oldest()) { (Some(newest), Some(oldest)) if newest.sequence == oldest.sequence => { part.push_str(&format!(" ({})", newest.describe(u16::MAX))); } (Some(newest), Some(oldest)) => { part.push_str(&format!( " ({}, oldest {})", newest.describe(u16::MAX), oldest.describe(u16::MAX) )); } _ => {} } part }) .collect(); Some(parts.join(" · ")) } } /// Why a run stopped short of the tree, as the tail of a sentence. pub fn describe_early_finish(reason: EarlyFinish) -> String { match reason { EarlyFinish::Requested => "the run was asked to publish what it had and stop".to_string(), EarlyFinish::StoreNearlyFull { free, floor } => format!( "the store has {} left, {} below the {} this backup was told to \ leave free", human_bytes(free), human_bytes(floor.saturating_sub(free)), human_bytes(floor) ), EarlyFinish::StoreAtMaximum { used, limit } => format!( "the repository holds {}, at the {} this backup was told to keep \ it under", human_bytes(used), human_bytes(limit) ), } } /// Why a limit the run was given is not being applied to it. pub fn describe_unapplied_limit(limit: StoreLimit) -> String { match limit { StoreLimit::MinimumFreeSpace => { "the store cannot say how much room is left on it, so no room is \ being left free" .to_string() } StoreLimit::MaximumStoreSize => { "the store cannot say how much it is holding, so the repository \ is not being kept to a size" .to_string() } } } fn describe_skip(reason: &SkipReason) -> String { match reason { SkipReason::Excluded => "excluded".to_string(), SkipReason::MountPoint => "mount point".to_string(), SkipReason::Vanished => "vanished".to_string(), SkipReason::Unreadable(detail) => format!("unreadable: {detail}"), SkipReason::SpecialFile => "special file".to_string(), SkipReason::UnsafePath => "unsafe path".to_string(), SkipReason::Internal => "beeping's own working data".to_string(), } } pub fn human_bytes(bytes: u64) -> String { const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; let mut value = bytes as f64; let mut unit = 0; while value >= 1024.0 && unit < UNITS.len() - 1 { value /= 1024.0; unit += 1; } if unit == 0 { format!("{bytes} B") } else { format!("{value:.1} {}", UNITS[unit]) } } /// Whole-second durations, since sub-second precision in a figure that /// counts hours is just noise. fn human_duration(elapsed: Duration) -> String { humantime::format_duration(Duration::from_secs(elapsed.as_secs())).to_string() } /// A count with thousands separators: these numbers reach the millions, /// where an unpunctuated run of digits cannot be read at a glance. pub fn count(value: u64) -> String { let digits = value.to_string(); let mut out = String::with_capacity(digits.len() + digits.len() / 3); for (position, digit) in digits.chars().enumerate() { if position > 0 && (digits.len() - position).is_multiple_of(3) { out.push(','); } out.push(digit); } out } /// The newest history lines that fit inside a bordered pane, oldest /// first, so the pane fills its whole height and appears to scroll. /// Each is cut to what the pane's width leaves it. fn visible_tail(lines: &VecDeque, area: ratatui::layout::Rect) -> Vec> { let height = area.height.saturating_sub(2) as usize; // borders let width = area.width.saturating_sub(2); lines .iter() .skip(lines.len().saturating_sub(height)) .map(|line| ListItem::new(line.render(width))) .collect() } /// The narrow columns of the stages table, and the single space ratatui /// puts between each pair of its five columns — what the two path /// columns have to share the rest of the width with. const STAGE_COLUMNS: [u16; 3] = [6, 7, 11]; const STAGE_GAPS: u16 = 4; /// How wide each of the two path columns may be inside a table of this /// total width, borders included. fn path_columns(width: u16) -> (u16, u16) { let paths = width .saturating_sub(2) .saturating_sub(STAGE_COLUMNS.iter().sum::() + STAGE_GAPS); (paths / 2, paths - paths / 2) } /// Fits a path into a column, keeping its tail. /// /// Clipping the other end would be worse than useless here: everything /// on screen is being backed up from the same tree, so the leading /// components are the part they all share, and the name at the end is /// the part that says which item this is. fn fit_path(path: &str, width: u16) -> String { let width = width as usize; if width == 0 { return String::new(); } let length = path.chars().count(); if length <= width { return path.to_string(); } let tail: String = path.chars().skip(length - width + 1).collect(); format!("…{tail}") } /// The stages table: one row per stage, in pipeline order, showing how /// many of its workers are occupied, how much work is queued for it, and /// the two items worth naming — the one it took up last and the one it /// has held longest, which is the one holding it up. The stages without /// workers of their own name their items too, once finalization reaches /// them. fn stages_table(dashboard: &Dashboard, width: u16) -> Table<'_> { let dim = Style::default().add_modifier(Modifier::DIM); let (newest, oldest) = path_columns(width); let rows = dashboard.stages.iter().map(|view| { Row::new([ Cell::from(view.stage.label()), Cell::from(Line::from(view.occupancy()).right_aligned()), Cell::from(Line::from(count(view.waiting)).right_aligned()), Cell::from( view.newest() .map_or(String::new(), |item| item.describe(newest)), ), Cell::from( view.oldest() .map_or(String::new(), |item| item.describe(oldest)), ), ]) }); Table::new( rows, [ Constraint::Length(STAGE_COLUMNS[0]), Constraint::Length(STAGE_COLUMNS[1]), Constraint::Length(STAGE_COLUMNS[2]), Constraint::Length(newest), Constraint::Fill(1), ], ) .header( Row::new([ Cell::from("stage"), Cell::from(Line::from("busy").right_aligned()), Cell::from(Line::from("waiting").right_aligned()), Cell::from("newest"), Cell::from("oldest"), ]) .style(dim), ) .block(Block::default().borders(Borders::ALL).title("stages")) } /// The last `width` samples of a history: as much of it as a pane that /// wide can draw. fn window(history: &VecDeque, width: u16) -> Vec { history .iter() .skip(history.len().saturating_sub(width as usize)) .copied() .collect() } /// A queue's recent depth as one line of block characters, newest at the /// right, scaled to the tallest sample shown. /// /// A sample of zero is left blank rather than drawn at the lowest block, /// so that a queue which ran dry is plainly empty rather than merely /// nearly so. fn spark(samples: &[u64], width: u16) -> String { const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let peak = samples.iter().copied().max().unwrap_or(0); if peak == 0 { return String::new(); } // History grows leftward from the newest sample, so a history too // short to fill the pane starts part-way across it let mut line = " ".repeat((width as usize).saturating_sub(samples.len())); for sample in samples { line.push(match *sample { 0 => ' ', value => BLOCKS[(value * 7 / peak) as usize], }); } line } /// The backlog pane: how deep each stage's queue has been over the last /// few minutes. /// /// The stages table says how much is waiting now; what it cannot say is /// which way that number is going, which is the difference between a run /// that is keeping up and one that is falling behind. Each stage is drawn /// to its own scale — these queues differ by orders of magnitude — so the /// peak beside it is what says how deep the tallest column is. fn backlog_table(dashboard: &Dashboard, width: u16) -> Table<'_> { let dim = Style::default().add_modifier(Modifier::DIM); let room = width .saturating_sub(2) .saturating_sub(STAGE_COLUMNS[0] + STAGE_COLUMNS[2] + 2); let rows = dashboard.stages.iter().map(move |view| { let samples = window(&view.history, room); let peak = samples.iter().copied().max().unwrap_or(0); Row::new([ Cell::from(view.stage.label()), Cell::from(spark(&samples, room)), Cell::from(Line::from(count(peak)).right_aligned()), ]) }); Table::new( rows, [ Constraint::Length(STAGE_COLUMNS[0]), Constraint::Fill(1), Constraint::Length(STAGE_COLUMNS[2]), ], ) .header( Row::new([ Cell::from("stage"), Cell::from("backlog"), Cell::from(Line::from("peak").right_aligned()), ]) .style(dim), ) .block( Block::default() .borders(Borders::ALL) .title(backlog_span(dashboard, room)), ) } /// The backlog pane's title, which is where its unlabelled horizontal /// axis gets its scale: the run time between the oldest sample drawn and /// the newest, which grows as history accumulates and then holds at /// whatever the pane's width covers. fn backlog_span(dashboard: &Dashboard, room: u16) -> String { let drawn = dashboard .stages .iter() .map(|view| view.history.len()) .max() .unwrap_or(0) .min(room as usize); // A single column spans nothing yet, and has no scale to give match drawn { 0 | 1 => "backlog".to_string(), columns => format!( "backlog, last {}", human_duration(BACKLOG_SAMPLE * (columns as u32 - 1)) ), } } /// What has yet to be visited: the paths each stage will take up next, /// nearest first. A backup names the directories still to be walked /// into; a restore names the entries still to be put back. /// /// Most of a pipeline's backlog is deliberately absent. Files the scan /// has already found, chunks in flight to the backend, and the record /// of what is already stored are all work the run has in hand — naming /// them would bury the frontier under thousands of lines that say /// nothing about where the run is going. An empty pane means the stages /// that do name their queues have nothing waiting. /// /// The stages come in pipeline order, and a pane too short for all of /// them lets them take turns claiming a line, so that a stage with a /// short queue is not buried under one with a long one. The last line /// accounts for everything waiting behind what is named — the whole /// depth of those queues, not merely the part the pipeline sends by /// name. fn pending_lines(dashboard: &Dashboard, area: ratatui::layout::Rect) -> Vec> { let height = area.height.saturating_sub(2) as usize; // borders let width = area.width.saturating_sub(2); if height == 0 { return Vec::new(); } let queues: Vec<(&'static str, &[String])> = dashboard .stages .iter() .filter_map(|view| { view.upcoming .as_deref() .map(|paths| (view.stage.label(), paths)) }) .collect(); let waiting: u64 = dashboard .stages .iter() .filter(|view| view.upcoming.is_some()) .map(|view| view.waiting) .sum(); let named: usize = queues.iter().map(|(_, paths)| paths.len()).sum(); // Room for every name, or for all but the last line, which says how // much is waiting behind them — except in a pane one line tall, // where naming one path is worth more than counting the rest let room = if waiting as usize > height && height > 1 { named.min(height - 1) } else { named.min(height) }; let mut share = vec![0usize; queues.len()]; let mut given = 0; while given < room { for (queue, (_, paths)) in queues.iter().enumerate() { if given == room { break; } if share[queue] < paths.len() { share[queue] += 1; given += 1; } } } let mut lines: Vec> = Vec::with_capacity(height); for (queue, (label, paths)) in queues.iter().enumerate() { for path in &paths[..share[queue]] { lines.push(ListItem::new(format!( "{label: given as u64 && lines.len() < height { lines.push(ListItem::new(format!( "… and {} more", count(waiting - given as u64) ))); } lines } /// Runs the full-screen dashboard until the pipeline finishes, returning /// a final summary line. Pressing `s`, `q`, Esc, or Ctrl-C requests /// suspension. pub fn dashboard( title: &str, events: Receiver, suspend: Arc, ) -> std::io::Result { let mut terminal = ratatui::init(); let result = dashboard_loop(title, &events, &suspend, &mut terminal); ratatui::restore(); result } fn dashboard_loop( title: &str, events: &Receiver, suspend: &AtomicBool, terminal: &mut ratatui::DefaultTerminal, ) -> std::io::Result { let mut dashboard = Dashboard::default(); let mut finished = false; while !finished { loop { match events.try_recv() { Ok(event) => dashboard.apply(event), Err(crossbeam_channel::TryRecvError::Empty) => break, Err(crossbeam_channel::TryRecvError::Disconnected) => { finished = true; break; } } } let suspending = suspend.load(Ordering::Acquire); terminal.draw(|frame| draw(frame, title, &dashboard, suspending))?; if crossterm::event::poll(Duration::from_millis(100))? && let TermEvent::Key(key) = crossterm::event::read()? { let ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); if matches!(key.code, KeyCode::Char('s' | 'q') | KeyCode::Esc) || ctrl_c { suspend.store(true, Ordering::Release); } } } Ok(dashboard.summary()) } /// Draws one frame: the run's totals, its stages, and three panes — what /// has been finished, how the queues ahead of it are trending, and what /// its workers have in their hands. fn draw(frame: &mut Frame, title: &str, dashboard: &Dashboard, suspending: bool) { // Both stage tables are exactly as tall as they need to be // — borders, a header, and one row per reported stage — so // the activity log and the pending pane get what is left. let stages_height = match dashboard.stages.len() { 0 => 0, rows => rows as u16 + 3, }; let [header, counters, stages, panes, skipped, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(4), Constraint::Length(stages_height), Constraint::Fill(1), Constraint::Length(1), Constraint::Length(1), ]) .areas(frame.area()); // What has happened on the left; what is happening and what // is coming on the right let [recent, current] = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(panes); // The backlog pane wants to be as tall as the stages table, and is // what gives way when the window is too short to leave the pending // pane anything worth showing let [backlog, pending] = Layout::vertical([Constraint::Max(stages_height), Constraint::Min(5)]).areas(current); let dim = Style::default().add_modifier(Modifier::DIM); frame.render_widget( Paragraph::new(Line::from(title.to_string())) .style(Style::default().add_modifier(Modifier::BOLD)), header, ); frame.render_widget( Paragraph::new(format!( "{}\nstatus: {}", dashboard.summary(), dashboard.status(suspending) )) .block(Block::default().borders(Borders::ALL).title("progress")), counters, ); frame.render_widget(stages_table(dashboard, stages.width), stages); frame.render_widget( List::new(visible_tail(&dashboard.recent, recent)) .block(Block::default().borders(Borders::ALL).title("activity")), recent, ); frame.render_widget(backlog_table(dashboard, backlog.width), backlog); frame.render_widget( List::new(pending_lines(dashboard, pending)) .block(Block::default().borders(Borders::ALL).title("pending")), pending, ); frame.render_widget(Paragraph::new(dashboard.skip_line()).style(dim), skipped); frame.render_widget( Paragraph::new("s / q / Esc / Ctrl-C: suspend (resume with the same command)"), footer, ); } /// Line-oriented reporter: skips are printed as they happen, progress /// every couple of seconds, and the final summary at the end. pub fn plain(events: Receiver) -> String { let mut dashboard = Dashboard::default(); let mut last_progress = Instant::now(); let stderr = std::io::stderr(); for event in events { if let Event::Skipped { path, reason } = &event { let _ = writeln!( stderr.lock(), "skipped {} ({})", path.display(), describe_skip(reason) ); } match event { Event::Checkpointing => { let _ = writeln!( stderr.lock(), "checkpointing: making what is stored so far restorable..." ); } Event::Finalizing => { let _ = writeln!(stderr.lock(), "finalizing..."); } Event::FinishingEarly(reason) => { let _ = writeln!( stderr.lock(), "finishing early: {}", describe_early_finish(reason) ); } Event::LimitNotApplied(limit) => { let _ = writeln!( stderr.lock(), "warning: {}", describe_unapplied_limit(limit) ); } _ => {} } dashboard.apply(event); if last_progress.elapsed() >= Duration::from_secs(2) { let mut line = format!("{} · {}\n", dashboard.summary(), dashboard.status(false)); // The stages, indented under the totals they belong to if let Some(stages) = dashboard.stage_line() { line.push_str(" "); line.push_str(&stages); line.push('\n'); } let _ = write!(stderr.lock(), "{line}"); last_progress = Instant::now(); } } dashboard.summary() } #[cfg(test)] mod tests { use super::*; use beeping::pipeline::{Progress, StageBacklog}; use std::path::PathBuf; fn lines(count: usize) -> VecDeque { (0..count) .map(|n| Activity::Note(format!("line-{n}"))) .collect() } fn texts(items: &[ListItem<'_>]) -> Vec { // ListItem has no accessor; rebuild via Text conversion items.iter().map(|item| format!("{:?}", item)).collect() } /// The paths the two columns would name, for comparing at a glance. fn named(view: &StageView) -> (Option<&str>, Option<&str>) { ( view.newest().map(|item| item.path.as_str()), view.oldest().map(|item| item.path.as_str()), ) } /// A stage's backlog with nothing named: a queue of something other /// than paths waiting for a worker. fn backlog(stage: Stage, waiting: u64) -> StageBacklog { StageBacklog { stage, waiting, upcoming: None, } } /// A stage's backlog with the head of its queue named. fn queued(stage: Stage, waiting: u64, upcoming: &[&str]) -> StageBacklog { StageBacklog { stage, waiting, upcoming: Some(upcoming.iter().map(PathBuf::from).collect()), } } fn began(stage: Stage, worker: usize, path: &str) -> Event { Event::Began { stage, worker, path: PathBuf::from(path), } } #[test] fn tall_panes_fill_with_the_newest_lines() { let area = ratatui::layout::Rect::new(0, 0, 80, 22); // 20 inner rows let history = lines(50); let items = visible_tail(&history, area); assert_eq!(items.len(), 20, "the pane's full height is used"); let rendered = texts(&items); assert!( rendered.first().unwrap().contains("line-30"), "oldest visible" ); assert!(rendered.last().unwrap().contains("line-49"), "newest last"); } #[test] fn short_histories_show_everything() { let area = ratatui::layout::Rect::new(0, 0, 80, 22); let history = lines(5); let items = visible_tail(&history, area); assert_eq!(items.len(), 5); } #[test] fn degenerate_panes_render_nothing() { let history = lines(10); for height in [0, 1, 2] { let area = ratatui::layout::Rect::new(0, 0, 80, height); assert!(visible_tail(&history, area).len() <= height as usize); } } /// The activity log's own pane is half the width it used to have, so /// its lines are fitted like the table's are: the verb whole, and as /// much of the path's tail as the rest of the line holds. #[test] fn activity_lines_keep_the_verb_and_the_end_of_the_path() { let stored = Activity::Item { verb: "stored", path: "/home/djarb/models/big.safetensors".to_string(), }; assert_eq!( stored.render(60), "stored /home/djarb/models/big.safetensors" ); assert_eq!(stored.render(24), "stored …ig.safetensors"); // Prose is not a path, and front-truncating it would only hide // the part that says what it is about let note = Activity::Note("resumed: totals include previous sessions".to_string()); assert_eq!(note.render(20), "resumed: totals include previous sessions"); } /// A run that stops short says why in the log, where it stays put: /// nothing else is happening by then, and finalizing a large /// manifest can take long enough for the question to come up. #[test] fn an_early_finish_says_why_in_the_activity_log() { let mut dashboard = Dashboard::default(); dashboard.apply(Event::FinishingEarly(EarlyFinish::StoreNearlyFull { free: 3 << 30, floor: 20 << 30, })); dashboard.apply(Event::Finalizing); let note = dashboard.recent.back().unwrap().render(80); assert_eq!( note, "finishing early: the store has 3.0 GiB left, 17.0 GiB below the \ 20.0 GiB this backup was told to leave free" ); // and the run carries on to publish what it has assert!(dashboard.status(false).starts_with("finalizing")); // The other limit on the store reads from the other end let mut dashboard = Dashboard::default(); dashboard.apply(Event::FinishingEarly(EarlyFinish::StoreAtMaximum { used: 500 << 30, limit: 500 << 30, })); assert_eq!( dashboard.recent.back().unwrap().render(80), "finishing early: the repository holds 500.0 GiB, at the 500.0 \ GiB this backup was told to keep it under" ); } /// A limit that cannot be applied is worth a line of its own: the /// run looks exactly like one being held to it otherwise. #[test] fn a_limit_that_cannot_be_applied_says_so() { let mut dashboard = Dashboard::default(); dashboard.apply(Event::LimitNotApplied(StoreLimit::MaximumStoreSize)); assert_eq!( dashboard.recent.back().unwrap().render(80), "the store cannot say how much it is holding, so the repository \ is not being kept to a size" ); } #[test] fn counts_are_grouped_in_threes() { assert_eq!(count(0), "0"); assert_eq!(count(999), "999"); assert_eq!(count(1000), "1,000"); assert_eq!(count(12345), "12,345"); assert_eq!(count(1234567890), "1,234,567,890"); } #[test] fn stages_appear_in_the_order_the_pipeline_mentions_them() { let mut dashboard = Dashboard::default(); for (stage, threads) in [(Stage::Scan, 2), (Stage::Chunk, 4), (Stage::Upload, 3)] { dashboard.apply(Event::Staffing { stage, threads }); } dashboard.apply(Event::Backlog(vec![backlog(Stage::Record, 17)])); let order: Vec = dashboard.stages.iter().map(|view| view.stage).collect(); assert_eq!( order, [Stage::Scan, Stage::Chunk, Stage::Upload, Stage::Record] ); // A stage nobody staffs is worked by finalization assert_eq!(dashboard.stages[3].occupancy(), "—"); assert_eq!(dashboard.stages[3].waiting, 17); } #[test] fn occupancy_follows_workers_in_and_out_of_work() { let mut dashboard = Dashboard::default(); dashboard.apply(Event::Staffing { stage: Stage::Chunk, threads: 3, }); dashboard.apply(began(Stage::Chunk, 0, "a.dat")); dashboard.apply(began(Stage::Chunk, 1, "b.dat")); assert_eq!(dashboard.stages[0].occupancy(), "2/3"); // Taking the next item replaces the worker's previous one dashboard.apply(began(Stage::Chunk, 1, "c.dat")); assert_eq!(dashboard.stages[0].occupancy(), "2/3"); dashboard.apply(Event::Idle { stage: Stage::Chunk, worker: 0, }); assert_eq!(dashboard.stages[0].occupancy(), "1/3"); dashboard.apply(Event::Idle { stage: Stage::Chunk, worker: 1, }); assert_eq!(dashboard.stages[0].occupancy(), "0/3"); assert_eq!(named(&dashboard.stages[0]), (None, None)); } #[test] fn a_stage_names_both_where_it_is_and_what_holds_it_up() { let mut dashboard = Dashboard::default(); dashboard.apply(began(Stage::Chunk, 0, "slow.iso")); dashboard.apply(began(Stage::Chunk, 1, "quick-a.txt")); dashboard.apply(began(Stage::Chunk, 1, "quick-b.txt")); assert_eq!( named(&dashboard.stages[0]), (Some("quick-b.txt"), Some("slow.iso")), "where it has got to, and what is holding it up" ); // A finished item is named by neither: with the fast worker // idle, one item is in hand and both columns name it dashboard.apply(Event::Idle { stage: Stage::Chunk, worker: 1, }); assert_eq!( named(&dashboard.stages[0]), (Some("slow.iso"), Some("slow.iso")) ); dashboard.apply(Event::Idle { stage: Stage::Chunk, worker: 0, }); assert_eq!( named(&dashboard.stages[0]), (None, None), "an idle stage names nothing rather than a stale item" ); } #[test] fn progress_belongs_to_the_item_in_hand() { let mut dashboard = Dashboard::default(); dashboard.apply(began(Stage::Chunk, 0, "big.iso")); dashboard.apply(Event::Progressed { stage: Stage::Chunk, worker: 0, done: 43, total: 100, }); let item = dashboard.stages[0].oldest().unwrap(); assert_eq!(item.describe(40), " 43% big.iso"); // The next item starts afresh rather than inheriting a // percentage from the last one dashboard.apply(began(Stage::Chunk, 0, "next.iso")); assert_eq!( dashboard.stages[0].oldest().unwrap().describe(40), "next.iso" ); // A percentage keeps its width; only the path gives way dashboard.apply(Event::Progressed { stage: Stage::Chunk, worker: 0, done: 7, total: 1000, }); let cell = dashboard.stages[0].oldest().unwrap().describe(10); assert_eq!(cell, " 0% ….iso"); assert_eq!(cell.chars().count(), 10, "the column is not overrun"); // Progress for a worker holding nothing is simply dropped dashboard.apply(Event::Idle { stage: Stage::Chunk, worker: 0, }); dashboard.apply(Event::Progressed { stage: Stage::Chunk, worker: 0, done: 1, total: 2, }); assert_eq!(named(&dashboard.stages[0]), (None, None)); } #[test] fn paths_are_cut_at_the_front_where_they_are_all_alike() { assert_eq!(fit_path("sub/file.dat", 20), "sub/file.dat"); assert_eq!(fit_path("sub/file.dat", 12), "sub/file.dat"); assert_eq!(fit_path("sub/file.dat", 8), "…ile.dat"); assert_eq!(fit_path("sub/file.dat", 1), "…"); assert_eq!(fit_path("sub/file.dat", 0), ""); // Cut on char boundaries, not byte offsets let accented = "café/ünïcøde.dat"; assert_eq!(accented.chars().count(), 16); assert_eq!(fit_path(accented, 8), "…øde.dat"); assert_eq!(fit_path(accented, 8).chars().count(), 8); } #[test] fn the_two_path_columns_share_what_the_narrow_ones_leave() { let (newest, oldest) = path_columns(110); assert_eq!(newest + oldest, 110 - 2 - 24 - 4); assert!(oldest >= newest, "odd remainders go to the last column"); // A cramped table asks for no path width rather than underflowing assert_eq!(path_columns(20), (0, 0)); } #[test] fn only_the_most_recent_skip_is_kept() { let mut dashboard = Dashboard::default(); for name in ["one", "two", "three"] { dashboard.apply(Event::Skipped { path: PathBuf::from(name), reason: SkipReason::Excluded, }); } dashboard.apply(Event::Totals(Session { progress: Progress { skipped: 3, ..Progress::default() }, elapsed: Duration::from_secs(1), })); assert_eq!(dashboard.skip_line(), "skipped 3 · last: three (excluded)"); // A resumed run inherits the count without inheriting a path let mut resumed = Dashboard::default(); assert_eq!(resumed.skip_line(), "skipped none"); resumed.apply(Event::Resumed(Session { progress: Progress { skipped: 1200, ..Progress::default() }, elapsed: Duration::from_secs(30), })); assert_eq!(resumed.skip_line(), "skipped 1,200"); assert!( dashboard.recent.is_empty(), "skips do not crowd the activity log" ); } #[test] fn the_line_reporter_says_what_every_stage_is_doing() { let mut dashboard = Dashboard::default(); assert_eq!(dashboard.stage_line(), None, "before the pipeline speaks"); dashboard.apply(Event::Staffing { stage: Stage::Chunk, threads: 16, }); dashboard.apply(began(Stage::Chunk, 4, "big.iso")); dashboard.apply(Event::Backlog(vec![ backlog(Stage::Chunk, 0), backlog(Stage::Record, 1489), ])); assert_eq!( dashboard.stage_line().unwrap(), "chunk 1/16 busy, 0 waiting (big.iso) · record 1,489 waiting" ); } /// Each queue is drawn against its own peak, since a scan backlog of /// half a million and an upload backlog of eight share a pane. #[test] fn a_backlog_is_drawn_against_its_own_peak() { assert_eq!(spark(&[0, 1, 5, 10], 4), " ▁▄█"); assert_eq!(spark(&[0, 100, 500, 1000], 4), " ▁▄█"); // A queue that has been empty throughout has nothing to say assert_eq!(spark(&[0, 0, 0], 8), ""); // History grows leftward from the newest sample assert_eq!(spark(&[2, 1], 6), " █▄"); // A pane narrower than the history shows the recent end of it, // which is what the caller's window hands over let history: VecDeque = (1..=10).collect(); assert_eq!(window(&history, 3), vec![8, 9, 10]); assert_eq!(window(&history, 40), (1..=10).collect::>()); } /// The display is fed several times a second; the backlog history is /// not, or the pane would hold a few seconds of noise. #[test] fn backlog_history_is_sampled_on_the_pipelines_clock() { let mut dashboard = Dashboard::default(); let report = |dashboard: &mut Dashboard, millis: u64, waiting: u64| { dashboard.apply(Event::Totals(Session { progress: Progress::default(), elapsed: Duration::from_millis(millis), })); dashboard.apply(Event::Backlog(vec![backlog(Stage::Chunk, waiting)])); }; for tick in 0..=50 { report(&mut dashboard, tick * 200, tick); } let view = &dashboard.stages[0]; // Ten seconds of reports, two seconds to the sample assert_eq!(view.history.len(), 6); assert_eq!(view.waiting, 50, "the depth itself is always current"); assert_eq!(*view.history.back().unwrap(), 50); // A second pipeline reporting into the same display starts the // history over rather than continuing the first one's report(&mut dashboard, 0, 7); let view = &dashboard.stages[0]; assert_eq!(view.history.len(), 1); assert_eq!(*view.history.back().unwrap(), 7); } #[test] fn transfers_and_finished_content_are_measured_separately() { let mut dashboard = Dashboard::default(); let totals = |elapsed, stored_bytes| { Event::Totals(Session { progress: Progress { stored_bytes, ..Progress::default() }, elapsed: Duration::from_secs(elapsed), }) }; // One huge file: chunks keep flowing, but nothing finishes dashboard.apply(totals(0, 0)); for second in 1..=4 { dashboard.apply(Event::Transferred { bytes: 3000 }); dashboard.apply(totals(second, 0)); } assert_eq!(dashboard.content_rate.per_second(), Some(0)); assert_eq!(dashboard.transfer_rate.per_second(), Some(3000)); let status = dashboard.status(false); assert!(status.contains("repository 2.9 KiB/s"), "{status}"); } #[test] fn throughput_is_measured_over_the_recent_window() { let mut rate = Rate::default(); assert_eq!(rate.per_second(), None, "nothing sampled yet"); rate.observe(Duration::from_secs(0), 0); assert_eq!(rate.per_second(), None, "one sample is not a rate"); for second in 1..=4 { rate.observe(Duration::from_secs(second), second * 1000); } assert_eq!(rate.per_second(), Some(1000)); // Ten seconds of double speed pushes the old samples out of the // window, leaving the current rate rather than the average for second in 5..=14 { rate.observe(Duration::from_secs(second), 4000 + (second - 4) * 2000); } assert_eq!(rate.per_second(), Some(2000)); } #[test] fn a_stalled_run_reports_no_throughput_rather_than_a_stale_one() { let mut rate = Rate::default(); for second in 0..=3 { rate.observe(Duration::from_secs(second), second * 1000); } // The bytes stop moving; the window fills with flat samples for second in 4..=20 { rate.observe(Duration::from_secs(second), 3000); } assert_eq!(rate.per_second(), Some(0)); } /// The pending pane names what each queue will hand over next, and /// accounts for everything waiting behind the names it has room for. /// /// The dashboard serves both pipelines and knows neither, so what it /// draws depends only on which stages name their queues — here, a /// restore's two, beside a stage whose backlog is not a queue of /// paths at all. #[test] fn the_pending_pane_names_what_the_queues_will_hand_out_next() { let mut dashboard = Dashboard::default(); assert!( pending_lines(&dashboard, ratatui::layout::Rect::new(0, 0, 40, 8)).is_empty(), "nothing queued yet" ); dashboard.apply(Event::Backlog(vec![ queued(Stage::Write, 2, &["/tree/src", "/tree/doc"]), queued( Stage::Apply, 400, &["/tree/a.dat", "/tree/b.dat", "/tree/c.dat"], ), backlog(Stage::Record, 9000), ])); let pane = ratatui::layout::Rect::new(0, 0, 40, 8); // 6 inner rows let rendered = texts(&pending_lines(&dashboard, pane)); assert_eq!(rendered.len(), 6); assert!(rendered[0].contains("write"), "{:?}", rendered[0]); assert!(rendered[0].contains("src"), "nearest first"); assert!(rendered[1].contains("doc")); assert!(rendered[2].contains("apply"), "{:?}", rendered[2]); assert!(rendered[2].contains("a.dat"), "then the next stage along"); assert!( rendered[5].contains("and 397 more"), "the rest of the two queues — and not the record queue, which \ holds what is already stored: {:?}", rendered[5] ); // Too short for all the names: every queue still gets a turn, // and the last line grows to cover what is left out let short = ratatui::layout::Rect::new(0, 0, 40, 4); // 2 inner rows let rendered = texts(&pending_lines(&dashboard, short)); assert_eq!(rendered.len(), 2); assert!(rendered[0].contains("src"), "{:?}", rendered[0]); assert!(rendered[1].contains("and 401 more"), "{:?}", rendered[1]); // One line is better spent naming something than counting let sliver = ratatui::layout::Rect::new(0, 0, 40, 3); // 1 inner row let rendered = texts(&pending_lines(&dashboard, sliver)); assert_eq!(rendered.len(), 1); assert!(rendered[0].contains("src"), "{:?}", rendered[0]); // A queue that drains takes its names off the pane with it dashboard.apply(Event::Backlog(vec![ queued(Stage::Write, 0, &[]), queued(Stage::Apply, 0, &[]), ])); assert!(pending_lines(&dashboard, pane).is_empty()); } /// The whole frame, as characters, for tests that care about how the /// panes divide the screen rather than about one pane's contents. fn rendered(dashboard: &Dashboard, width: u16, height: u16) -> Vec { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); terminal .draw(|frame| draw(frame, "beeping backup", dashboard, false)) .unwrap(); let buffer = terminal.backend().buffer().clone(); (0..height) .map(|y| { (0..width) .map(|x| buffer[(x, y)].symbol()) .collect::() }) .collect() } /// The panes below the stages table divide the screen in two: what /// has been finished on the left, and what the queues have been /// doing and will do next on the right. #[test] fn the_lower_panes_split_the_width_between_history_and_what_is_ahead() { let mut dashboard = Dashboard::default(); dashboard.apply(Event::Staffing { stage: Stage::Scan, threads: 2, }); dashboard.apply(began(Stage::Scan, 0, "/tree/models")); dashboard.apply(Event::Stored { path: PathBuf::from("/tree/notes/a.txt"), bytes: 12, }); for tick in 0..=10 { dashboard.apply(Event::Totals(Session { progress: Progress { scanned: 40 + tick, stored_files: 300 + tick * 9, stored_bytes: (12 + tick) * 1024 * 1024, ..Progress::default() }, elapsed: Duration::from_secs(tick * 2), })); dashboard.apply(Event::Backlog(vec![queued( Stage::Scan, tick * 40, &["/tree/notes", "/tree/models"], )])); } let screen = rendered(&dashboard, 100, 20); // A pane's title is where its top border opens, which is also // the only place these words appear as titles let titled = |name: &str| { let opens = format!("┌{name}"); screen .iter() .position(|line| line.contains(&opens)) .unwrap_or_else(|| panic!("no {name} pane in\n{}", screen.join("\n"))) }; // activity and backlog share a row of titles; pending is under // the backlog pane, which is as tall as the stages table assert_eq!(titled("activity"), titled("backlog")); assert!(titled("pending") > titled("backlog")); let titles = &screen[titled("activity")]; assert!( titles.contains("backlog, last 20s"), "the pane says how much of the run it is drawing: {titles}" ); let split = titles.split("┌backlog").next().unwrap().chars().count(); assert!((45..55).contains(&split), "{titles}"); // Each pane holds what belongs to it, and nothing has spilled assert!(screen.iter().any(|line| line.contains("stored"))); assert!( screen[titled("pending") + 1].contains("/tree/notes"), "{}", screen[titled("pending") + 1] ); // Title, header, then the one stage this run has reported assert!( screen[titled("backlog") + 2].contains('█'), "a queue that has been growing all run should show it: {}", screen[titled("backlog") + 2] ); } #[test] fn totals_come_from_the_pipeline_rather_than_being_re_derived() { let mut dashboard = Dashboard::default(); // Per-item events feed the activity log, not the counters dashboard.apply(Event::Stored { path: PathBuf::from("a.dat"), bytes: 10, }); assert_eq!(dashboard.summary(), "no activity"); dashboard.apply(Event::Totals(Session { progress: Progress { scanned: 12, stored_files: 3400, stored_bytes: 5 * 1024 * 1024, ..Progress::default() }, elapsed: Duration::from_secs(90), })); assert_eq!( dashboard.summary(), "12 directories scanned, 3,400 files stored (5.0 MiB)" ); assert_eq!( dashboard.status(false), "running · elapsed 1m 30s", "one sample in is too early for a throughput figure" ); assert!(dashboard.status(true).starts_with("suspending")); } }