From 430d5c6eb811aa8a1854f2d17cc449d5bdb0b3b7 Mon Sep 17 00:00:00 2001 From: Alan Hanson Date: Tue, 15 Sep 2026 09:35:12 -0700 Subject: [PATCH 1/2] ctop: Give each row a sparkline of its job rate The DELTA column says how much work an upstairs has done since the last update, which does not distinguish one that has been busy for a minute from one that just woke up. Keep the last hundred deltas per session and graph them in whatever width the columns leave over. Every row is scaled against the largest sample on screen rather than its own range, so the rows can be compared with each other. The cost is that a quiet session next to a busy one reads as flat, which is the honest answer to "is this one doing anything". The axis counts samples, not time: a session that stops reporting adds nothing, so its sparkline keeps its shape rather than growing a gap. This is something to address in a future PR. --- ctop/src/main.rs | 89 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/ctop/src/main.rs b/ctop/src/main.rs index 7e2009d51..9899b9dbd 100644 --- a/ctop/src/main.rs +++ b/ctop/src/main.rs @@ -68,6 +68,11 @@ const STALE_THRESHOLD_SECS: u64 = 5; /// row would otherwise sit there forever. const REMOVE_THRESHOLD_SECS: u64 = 30; +/// How many job deltas to remember per session. More than fits across +/// a wide terminal, so the sparkline has samples in hand for whatever +/// width it is given. +const MAX_DELTA_HISTORY: usize = 100; + #[derive(Parser, Debug)] #[clap(name = "ctop", term_width = 80)] #[clap( @@ -105,6 +110,10 @@ struct SessionData { /// When the last record for this session arrived, which is what /// makes a row stale and eventually removes it. last_updated: Instant, + + /// The last `MAX_DELTA_HISTORY` job deltas, oldest first, for the + /// sparkline to draw. + delta_history: VecDeque, } /// What the reader has collected, for the display to draw. @@ -204,15 +213,21 @@ async fn reader_loop( let mut state = state.write().await; match state.sessions.get_mut(&wrapper.status.session_id) { Some(session) => { - session.current_delta = - Some(job_id.saturating_sub(session.last_job_id)); + let delta = job_id.saturating_sub(session.last_job_id); + + session.delta_history.push_back(delta); + if session.delta_history.len() > MAX_DELTA_HISTORY { + session.delta_history.pop_front(); + } + + session.current_delta = Some(delta); session.last_job_id = job_id; session.pid = wrapper.pid; session.dtrace_info = wrapper.status; session.last_updated = Instant::now(); } // First record for this session, so there is nothing to - // take a delta against yet. + // take a delta against yet, and nothing to plot. None => { state.sessions.insert( wrapper.status.session_id.clone(), @@ -222,6 +237,7 @@ async fn reader_loop( last_job_id: job_id, current_delta: None, last_updated: Instant::now(), + delta_history: VecDeque::new(), }, ); } @@ -338,6 +354,46 @@ fn reselect_if_gone(state: &mut CtopState) { } } +/// Draw a session's delta history as one block character per sample. +/// +/// Newest sample at the right, older ones trailing off to the left, +/// and the left padded with spaces when there are fewer samples than +/// columns. The axis counts samples rather than time: a session that +/// stops reporting adds nothing, so its sparkline holds its shape +/// rather than showing a gap. This is something to make better in +/// future updates. It's also.. complicated.. as we would have to decide +/// at what timeout have we given up on a session reporting, 3 seconds? 5? +/// So, for now, I'm punting that decision to later. +/// +/// Heights are scaled against `global_max` taken across every session +/// on screen. A busy row looks busier than a quiet one rather than +/// every row filling its own range. +fn render_sparkline( + history: &VecDeque, + width: usize, + global_max: u64, +) -> String { + const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + // A max of zero means every sample is zero, and dividing by it + // would not go well. + let max = global_max.max(1); + + // The newest `width` samples, oldest first. + let bars: String = history + .iter() + .skip(history.len().saturating_sub(width)) + .map(|&value| { + let step = (value as f64 / max as f64 * 7.0) as usize; + BLOCKS[step.min(BLOCKS.len() - 1)] + }) + .collect(); + + // Right align, so the newest sample sits in the same column on + // every row however much history each one has. + format!("{bars:>width$}") +} + /// Has this session gone quiet long enough to mark its row? fn is_stale(session: &SessionData, now: Instant) -> bool { now.duration_since(session.last_updated) @@ -361,6 +417,7 @@ fn render_table_view( table_state: &mut TableState, now: Instant, timestamp: u64, + global_max: u64, ) -> io::Result<()> { terminal.draw(|f| { let chunks = Layout::default() @@ -378,6 +435,14 @@ fn render_table_view( let selected = table_state.selected(); + // Rows carry two indicator characters, so the header is padded + // by the same amount to keep the columns lined up. + let header = format!(" {}", format_header(display_fields)); + + // Whatever width the columns do not use goes to the sparkline. + let spark_width = + (chunks[1].width as usize).saturating_sub(header.chars().count()); + let rows: Vec = sessions .iter() .enumerate() @@ -392,14 +457,12 @@ fn render_table_view( s.current_delta, display_fields, ); - Row::new(vec![format!("{cursor}{stale}{row}")]) + let spark = + render_sparkline(&s.delta_history, spark_width, global_max); + Row::new(vec![format!("{cursor}{stale}{row}{spark}")]) }) .collect(); - // Rows carry two indicator characters, so the header is padded - // by the same amount to keep the columns lined up. - let header = format!(" {}", format_header(display_fields)); - // One full width column: format_row has already laid the row // out, and the table clips it to the area instead of letting a // row wider than the terminal wrap and push the layout apart. @@ -485,6 +548,15 @@ async fn display_loop( .unwrap_or_default() .as_secs(); + // One scale for every sparkline on screen, so a busy row + // looks busier than a quiet one. + let global_max = sessions + .iter() + .flat_map(|s| s.delta_history.iter()) + .copied() + .max() + .unwrap_or(1); + render_table_view( &mut terminal, &sessions, @@ -492,6 +564,7 @@ async fn display_loop( &mut table_state, now, timestamp, + global_max, )?; if state.reader_done { From 6b7b208649e2800699350cb6924a39c88b251e36 Mon Sep 17 00:00:00 2001 From: Alan Hanson Date: Thu, 17 Sep 2026 16:09:09 -0700 Subject: [PATCH 2/2] make s change what a sessions delta is measured against --- ctop/src/main.rs | 121 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 89 insertions(+), 32 deletions(-) diff --git a/ctop/src/main.rs b/ctop/src/main.rs index 9899b9dbd..00681c05e 100644 --- a/ctop/src/main.rs +++ b/ctop/src/main.rs @@ -98,6 +98,41 @@ struct Args { output: Vec, } +/// What the sparklines are measured against. +/// +/// Both settings start at zero; they differ in what counts as a full +/// height bar. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum SparkScale { + /// The busiest sample on screen, so the rows can be compared with + /// each other. A quiet session next to a busy one reads as flat. + #[default] + Global, + + /// Each session's own busiest sample, so every row fills its + /// height and shows its shape. Nothing can be read across rows: a + /// session doing ten jobs a second looks like one doing ten + /// thousand. + PerSession, +} + +impl SparkScale { + fn toggled(self) -> Self { + match self { + SparkScale::Global => SparkScale::PerSession, + SparkScale::PerSession => SparkScale::Global, + } + } + + /// What to call it on the footer. + fn label(self) -> &'static str { + match self { + SparkScale::Global => "all", + SparkScale::PerSession => "self", + } + } +} + /// The most recent record for one session and what we recorded from /// the record before it. #[derive(Debug)] @@ -127,6 +162,9 @@ struct CtopState { /// cursor onto a different session if one above expire or arrives. selected_session: Option, + /// What the sparklines are measured against. + spark_scale: SparkScale, + /// Set when the dtrace command is no longer running, which tells /// the display to stop. Otherwise a dtrace that never started /// would leave an empty screen up with no explanation. @@ -284,24 +322,35 @@ fn is_quit(key_event: KeyEvent) -> bool { ) } -/// Apply one key to the cursor. Returns true if anything moved. -fn handle_navigation(key_event: KeyEvent, state: &mut CtopState) -> bool { - let down = match key_event { +/// Apply one key. Returns true if anything changed. +fn handle_key(key_event: KeyEvent, state: &mut CtopState) -> bool { + match key_event { + KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::NONE, + .. + } => { + state.spark_scale = state.spark_scale.toggled(); + true + } KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::NONE, .. - } => false, + } => { + move_selection(state, false); + true + } KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::NONE, .. - } => true, - _ => return false, - }; - - move_selection(state, down); - true + } => { + move_selection(state, true); + true + } + _ => false, + } } /// Session ids in the order their rows are drawn. @@ -416,9 +465,23 @@ fn render_table_view( display_fields: &[DtraceDisplay], table_state: &mut TableState, now: Instant, - timestamp: u64, - global_max: u64, + spark_scale: SparkScale, ) -> io::Result<()> { + // The busiest sample on screen, which is what the Global setting + // measures against. Derived from the sessions being drawn rather + // than passed in, since nothing else needs it. + let global_max = sessions + .iter() + .flat_map(|s| s.delta_history.iter()) + .copied() + .max() + .unwrap_or(1); + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + terminal.draw(|f| { let chunks = Layout::default() .constraints([ @@ -457,8 +520,16 @@ fn render_table_view( s.current_delta, display_fields, ); + // Both settings measure up from zero; they differ in + // what a full height bar means. + let max = match spark_scale { + SparkScale::Global => global_max, + SparkScale::PerSession => { + s.delta_history.iter().copied().max().unwrap_or(1) + } + }; let spark = - render_sparkline(&s.delta_history, spark_width, global_max); + render_sparkline(&s.delta_history, spark_width, max); Row::new(vec![format!("{cursor}{stale}{row}{spark}")]) }) .collect(); @@ -490,8 +561,9 @@ fn render_table_view( f.render_widget( Paragraph::new(format!( - "[up/down: Move | 'q': Quit] \ - > = cursor, * = stale ({STALE_THRESHOLD_SECS}s)" + "[up/down: Move | 's': Scale | 'q': Quit] \ + scale: {} * = stale ({STALE_THRESHOLD_SECS}s)", + spark_scale.label(), )), footer[0], ); @@ -543,28 +615,13 @@ async fn display_loop( }, )); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - // One scale for every sparkline on screen, so a busy row - // looks busier than a quiet one. - let global_max = sessions - .iter() - .flat_map(|s| s.delta_history.iter()) - .copied() - .max() - .unwrap_or(1); - render_table_view( &mut terminal, &sessions, display_fields, &mut table_state, now, - timestamp, - global_max, + state.spark_scale, )?; if state.reader_done { @@ -582,7 +639,7 @@ async fn display_loop( if is_quit(key_event) { return Ok(()); } - handle_navigation(key_event, &mut *state.write().await); + handle_key(key_event, &mut *state.write().await); } } }