Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 158 additions & 28 deletions ctop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -93,6 +98,41 @@ struct Args {
output: Vec<DtraceDisplay>,
}

/// 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)]
Expand All @@ -105,6 +145,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<u64>,
}

/// What the reader has collected, for the display to draw.
Expand All @@ -118,6 +162,9 @@ struct CtopState {
/// cursor onto a different session if one above expire or arrives.
selected_session: Option<String>,

/// 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.
Expand Down Expand Up @@ -204,15 +251,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(),
Expand All @@ -222,6 +275,7 @@ async fn reader_loop(
last_job_id: job_id,
current_delta: None,
last_updated: Instant::now(),
delta_history: VecDeque::new(),
},
);
}
Expand Down Expand Up @@ -268,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.
Expand Down Expand Up @@ -338,6 +403,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.
Comment on lines +417 to +419

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be toggle-able I think, as outliers will basically hide all others.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alternative would be to show each 100 samples (or whatever size, I plan to change that to a longer number and do more in the future) scaled to a single sessions min/max, is that what you would toggle between?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is with the toggle.

First, the new way, where each line has its own relative size:

ctop - Unix timestamp: 1789686244                                                              
     PID  SESSION DS0 DS1 DS2    NEXTJOB DELTA EXTL RECD RECN
     215 6f4b5c51 ACT ACT ACT      76800     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▆▂▁▁▁▂▂▁▁▁▁▁
     219 11dd7add ACT ACT ACT     224353     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▆▂▁▁▁▂▂▁▁▁▁▁▁▁▁▁▁▁
     219 1e92c6de ACT ACT ACT    2462881     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 2577e713 ACT ACT ACT    2036432     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 2713071d ACT ACT ACT    2434910     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 32364ede ACT ACT ACT    2022742     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 aa899cc4 ACT ACT ACT    2434692     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 d01a3542 ACT ACT ACT    2462944     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
    4423 b1bee2c9 ACT ACT ACT      84319     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
>   9825 16e00792 ACT ACT ACT     211941     1    0    0    0▁▁▁▁▁▁▁▁▁▁▁█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▂▂
    9857 1c3665ee ACT ACT ACT     204256     1    0    0    0▁▁▅▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▃
   16567 065a24fe ACT ACT ACT   47492651  2701    0    0    0▆▇▆▇▆▇▆▇▆▇▇▇▇▆▇▆▇▆▇▆▇▆▇▆▇▆▇▆▇▇▇▇▇▇▆▇▆▇▆▇▆▇▆
   16567 22617d36 ACT ACT ACT  111818570  6090    0    0    0▆▇▅▅▅▆▇▅▅▅▅▆▅▇▅▅▆▄▇▅▅▅▅▇▅▅▅▅█▅▅▅▅▇▆▅▅▅▆▆▅▅▅
   16567 2cbf490b ACT ACT ACT   29637003  1822    0    0    0▆▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   16567 3cd6ae66 NEW NEW NEW    5809526     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
   16567 c626d5c6 ACT ACT ACT   29621532  1942    0    0    0▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇█▇▇▇▇▇▇▇▇▇▇▇▆▇▇▇▇▇▇▇▇▇▆▇▇
   16567 eb32a0db ACT ACT ACT   30129543  1908    0    0    0█▆▆▇▇▆▇▇▇▆▇▇▆▇▇▇▆▇▇▆▇▇▇▇▇▇▆▇▇▇▆▇▇▆▇▇▇▆▇▇▇▇▇


[up/down: Move | 's': Scale | 'q': Quit]  scale: self  * = stale (5s)                            [10/17]

Then, the same(ish) timeframe but with one common scale:

ctop - Unix timestamp: 1789686362                                                             
     PID  SESSION DS0 DS1 DS2    NEXTJOB DELTA EXTL RECD RECN
     215 6f4b5c51 ACT ACT ACT      76806     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 11dd7add ACT ACT ACT     224411     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 1e92c6de ACT ACT ACT    2462881     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 2577e713 ACT ACT ACT    2036432     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 2713071d ACT ACT ACT    2434910     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 32364ede ACT ACT ACT    2022742     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 aa899cc4 ACT ACT ACT    2434692     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
     219 d01a3542 ACT ACT ACT    2462944     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
>   4423 b1bee2c9 ACT ACT ACT      84331     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
    9825 16e00792 ACT ACT ACT     212011     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
    9857 1c3665ee ACT ACT ACT     204319     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
   16567 065a24fe ACT ACT ACT   47848325  2898    0    0    0▃▃▃▂▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃
   16567 22617d36 ACT ACT ACT  112636667  5974    0    0    0▄▇▅▅▇▆▆▅▅▆▅▇▅▅▅▅▇▆▅▅▅▆▆▇▆▅▅▅▆▇▆▅▅▆▇▆▆▅▆▆▆▆▅
   16567 2cbf490b ACT ACT ACT   29840469  1667    0    0    0▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▁▂▂▂▂▂▂▂▂▂
   16567 3cd6ae66 NEW NEW NEW    5809526     0    0    0    0▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
   16567 c626d5c6 ACT ACT ACT   29825243  1666    0    0    0▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂
   16567 eb32a0db ACT ACT ACT   30325929  1323    0    0    0▂▂▂▂▂▂▂▁▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▁▂▂▂▁▂▂▂▂▂▂▂▁▂▂▂▂

[up/down: Move | 's': Scale | 'q': Quit]  scale: all  * = stale (5s)                              [9/17]

fn render_sparkline(
history: &VecDeque<u64>,
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)
Expand All @@ -360,8 +465,23 @@ fn render_table_view(
display_fields: &[DtraceDisplay],
table_state: &mut TableState,
now: Instant,
timestamp: 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([
Expand All @@ -378,6 +498,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<Row> = sessions
.iter()
.enumerate()
Expand All @@ -392,14 +520,20 @@ fn render_table_view(
s.current_delta,
display_fields,
);
Row::new(vec![format!("{cursor}{stale}{row}")])
// 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, 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.
Expand Down Expand Up @@ -427,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],
);
Expand Down Expand Up @@ -480,18 +615,13 @@ async fn display_loop(
},
));

let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();

render_table_view(
&mut terminal,
&sessions,
display_fields,
&mut table_state,
now,
timestamp,
state.spark_scale,
)?;

if state.reader_done {
Expand All @@ -509,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);
}
}
}
Expand Down
Loading