use ratatui::style::Style;
use ratatui::text::{Line, Span};
use shep_core::status::ProcStatus;
use super::super::app::App;
use super::super::theme::Palette;
use super::cell;
use super::flock::take_columns;
use crate::output::width::char_columns;
use crate::output::{human_bytes, human_duration};
#[must_use]
pub fn strip_line(app: &App, width: u16) -> Line<'static> {
Line::from(Ink::fit(&runs(app), width, app.data_palette()))
}
struct Run {
text: String,
ink: Ink,
}
#[derive(Clone, Copy)]
enum Ink {
Muted,
Load,
Mem,
}
impl Ink {
fn style(self, palette: Palette) -> Style {
match self {
Ink::Muted => palette.muted(),
Ink::Load => palette.attention(),
Ink::Mem => palette.sky(),
}
}
fn fit(runs: &[Run], width: u16, palette: Palette) -> Vec<Span<'static>> {
let width = usize::from(width);
let total: usize = runs
.iter()
.flat_map(|run| run.text.chars())
.map(char_columns)
.sum();
if total <= width {
let mut spans: Vec<Span<'static>> = runs
.iter()
.map(|run| Span::styled(run.text.clone(), run.ink.style(palette)))
.collect();
spans.push(Span::styled(" ".repeat(width - total), palette.muted()));
return spans;
}
if width == 0 {
return vec![Span::raw(String::new())];
}
let budget = width - 1;
let mut spans = Vec::new();
let mut used = 0;
for run in runs {
if used >= budget {
break;
}
let (kept, taken) = take_columns(&run.text, budget - used);
used += taken;
if !kept.is_empty() {
spans.push(Span::styled(kept, run.ink.style(palette)));
}
}
spans.push(Span::styled("…", palette.muted()));
if budget > used {
spans.push(Span::styled(" ".repeat(budget - used), palette.muted()));
}
spans
}
}
fn load_gauge(load: f64, cores: Option<usize>) -> String {
match cores.filter(|&count| count > 0) {
Some(cores) => {
let percent = (load / cores as f64 * 100.0).round() as u64;
cell::gauge(percent, Some(100), 10)
}
None => cell::gauge(0, None, 10),
}
}
fn summary(app: &App) -> String {
let rows = app.all_rows();
let errored = rows
.iter()
.filter(|row| row.info.status == ProcStatus::Errored)
.count();
let parked = rows
.iter()
.filter(|row| {
row.info
.pending
.as_deref()
.is_some_and(|fields| !fields.is_empty())
})
.count();
format!("{errored} errored · {parked} parked")
}
fn runs(app: &App) -> Vec<Run> {
let mut out = Vec::with_capacity(9);
let sep = || Run {
text: " ".to_string(),
ink: Ink::Muted,
};
match app.host() {
Some(host) => {
let (one, five, fifteen) = host.load;
out.push(Run {
text: "host load ".to_string(),
ink: Ink::Muted,
});
out.push(Run {
text: load_gauge(one, host.cores),
ink: Ink::Load,
});
let load_suffix = match host.cores {
Some(cores) => format!(" {one:.2} {five:.2} {fifteen:.2} / {cores} cores "),
None => format!(" {one:.2} {five:.2} {fifteen:.2} "),
};
out.push(Run {
text: load_suffix,
ink: Ink::Muted,
});
out.push(sep());
out.push(Run {
text: "host mem ".to_string(),
ink: Ink::Muted,
});
out.push(Run {
text: cell::gauge(host.memory_used_bytes, Some(host.memory_total_bytes), 10),
ink: Ink::Mem,
});
out.push(Run {
text: format!(
" {} / {} ",
human_bytes(host.memory_used_bytes),
human_bytes(host.memory_total_bytes)
),
ink: Ink::Muted,
});
out.push(sep());
out.push(Run {
text: summary(app),
ink: Ink::Muted,
});
}
None if app.host_unsupported() => {
out.push(Run {
text: "host usage is not available on this platform".to_string(),
ink: Ink::Muted,
});
out.push(sep());
out.push(Run {
text: summary(app),
ink: Ink::Muted,
});
}
None => {
out.push(Run {
text: "host not read yet".to_string(),
ink: Ink::Muted,
});
out.push(sep());
out.push(Run {
text: summary(app),
ink: Ink::Muted,
});
}
}
let rows = app.all_rows();
let cpu: Option<f32> = rows
.iter()
.filter_map(|row| app.cpu_now(row.info.id))
.fold(None, |sum, value| Some(sum.unwrap_or(0.0) + value));
let mem: Option<u64> = rows
.iter()
.filter_map(|row| row.info.memory_bytes)
.fold(None, |sum, value| Some(sum.unwrap_or(0) + value));
let cpu_text = cpu.map_or_else(
|| "flock cpu -".to_string(),
|cpu| format!("flock cpu {cpu:.1}%"),
);
out.push(sep());
out.push(Run {
text: format!(
"{cpu_text} {}",
cell::sparkline(app.flock_cpu_history(), 8, flock_cpu_ceiling(app)),
),
ink: Ink::Muted,
});
out.push(sep());
out.push(Run {
text: mem.map_or_else(
|| "flock mem -".to_string(),
|mem| format!("flock mem {}", human_bytes(mem)),
),
ink: Ink::Muted,
});
if let Some(host) = app.host() {
out.push(sep());
out.push(Run {
text: format!("up {}", human_duration(host.uptime_seconds * 1_000)),
ink: Ink::Muted,
});
}
out
}
fn flock_cpu_ceiling(app: &App) -> f32 {
app.flock_cpu_history().iter().copied().fold(2.0, f32::max)
}
#[cfg(test)]
mod tests {
use super::super::MIN_TERM_WIDTH;
use super::super::fixtures::{
app_with, flock_of, plain, rendered, sample, with_host, with_host_none,
};
use super::*;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use std::ffi::OsStr;
fn fixture_with(flock: Vec<ProcessInfo>) -> App {
app_with(flock, plain())
}
#[test]
fn the_load_gauge_measures_against_the_core_count() {
assert_eq!(load_gauge(5.12, Some(14)), "████░░░░░░");
}
#[test]
fn a_host_that_cannot_report_cores_draws_no_load_gauge() {
assert_eq!(load_gauge(5.12, None), "░░░░░░░░░░");
}
#[test]
fn the_summary_counts_errored_and_parked_sheep() {
let app = fixture_with(vec![
ProcessInfo::builder(1, "flaky", ProcStatus::Errored).build(),
ProcessInfo::builder(2, "catcher", ProcStatus::Online)
.pending(Some(vec!["max_memory".to_string(), "err_file".to_string()]))
.build(),
]);
assert_eq!(summary(&app), "1 errored · 1 parked");
}
#[test]
fn every_segment_names_whose_number_it_is() {
let app = with_host(sample(), flock_of(4, 1));
let line = rendered(&strip_line(&app, 200));
for segment in ["host load", "host mem", "flock cpu", "flock mem", "up "] {
assert!(line.contains(segment), "missing {segment:?} in {line:?}");
}
}
#[test]
fn a_narrow_strip_truncates_visibly_and_keeps_the_load_average() {
let app = with_host(sample(), flock_of(4, 1));
let narrow = rendered(&strip_line(&app, 40));
assert!(narrow.starts_with("host load"), "got {narrow:?}");
assert!(
narrow.ends_with('…'),
"a truncation the operator can see: {narrow:?}"
);
assert!(
!narrow.contains("up "),
"`up` is the first thing off the end"
);
let floor = rendered(&strip_line(&app, MIN_TERM_WIDTH));
assert!(floor.starts_with("host load"), "got {floor:?}");
let full = rendered(&strip_line(&app, 200));
assert!(!full.contains('…'));
assert!(
full.contains("up 6d"),
"the last segment is there: {full:?}"
);
}
#[test]
fn a_run_too_narrow_for_its_first_character_contributes_nothing() {
let narrow_runs = [
Run {
text: "ab".to_owned(),
ink: Ink::Muted,
},
Run {
text: "羊".to_owned(),
ink: Ink::Muted,
},
Run {
text: "cd".to_owned(),
ink: Ink::Muted,
},
];
let line = Line::from(Ink::fit(
&narrow_runs,
4,
Palette::detect(Some(OsStr::new("1")), None, None),
));
assert_eq!(rendered(&line), "abc…");
let built = runs(&with_host(sample(), flock_of(4, 1)));
assert!(built.len() > 1, "an empty walk would pass this vacuously");
for run in &built {
let first = run.text.chars().next().expect("every run has text");
assert_eq!(char_columns(first), 1, "{:?} starts a run", run.text);
}
}
#[test]
fn a_flock_with_no_readings_shows_a_dash_and_not_a_zero() {
let app = with_host(
sample(),
vec![ProcessInfo::builder(1, "web", ProcStatus::Errored).build()],
);
let line = rendered(&strip_line(&app, 200));
assert!(line.contains("flock cpu -"), "got {line:?}");
assert!(line.contains("flock mem -"), "got {line:?}");
assert!(!line.contains("0.0%"));
}
#[test]
fn an_unread_host_says_which_of_the_two_reasons_it_is() {
let unsupported = with_host_none(flock_of(4, 1), true);
assert!(
rendered(&strip_line(&unsupported, 200))
.contains("host usage is not available on this platform")
);
let not_yet = with_host_none(flock_of(4, 1), false);
assert!(rendered(&strip_line(¬_yet, 200)).contains("host not read yet"));
assert!(rendered(&strip_line(&unsupported, 200)).contains("flock cpu"));
}
#[test]
fn the_flock_totals_ignore_the_filter() {
use std::time::{Duration, Instant};
use super::super::super::app::{Control, Msg};
let row = |cpu_ms: u64| {
ProcessInfo::builder(1, "web", ProcStatus::Online)
.cpu_ms(Some(cpu_ms))
.build()
};
let t0 = Instant::now();
let mut app = App::new(
plain(),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![row(0)],
at: t0,
});
let t1 = t0 + Duration::from_secs(2);
app.update(Msg::Snapshot {
rows: vec![row(2_000)],
at: t1,
});
app.update(Msg::Host {
sample: Some(sample()),
});
let full = rendered(&strip_line(&app, 200));
assert!(full.contains("flock cpu 100.0%"), "sanity: got {full:?}");
app.set_filter_for_tests("zzz");
assert!(app.rows().is_empty(), "sanity: the filter matched nothing");
let filtered = rendered(&strip_line(&app, 200));
assert_eq!(
full, filtered,
"the strip must not change when the table's filter does: {filtered:?}"
);
assert!(
!filtered.contains("flock cpu -"),
"a filter matching nothing is not the same as no reading arriving: {filtered:?}"
);
}
#[test]
fn the_flock_figure_sums_the_same_sheep_the_flock_series_does() {
use std::time::{Duration, Instant};
use super::super::super::app::{Control, Msg};
let rows = |cpu_ms: [Option<u64>; 3]| {
vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.cpu_ms(cpu_ms[0])
.build(),
ProcessInfo::builder(2, "api", ProcStatus::Online)
.cpu_ms(cpu_ms[1])
.build(),
ProcessInfo::builder(3, "cron", ProcStatus::Stopped)
.cpu_ms(cpu_ms[2])
.build(),
]
};
let t0 = Instant::now();
let mut app = App::new(
plain(),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: rows([Some(0), Some(0), None]),
at: t0,
});
let t1 = t0 + Duration::from_secs(2);
app.update(Msg::Snapshot {
rows: rows([Some(2_000), Some(4_000), None]),
at: t1,
});
let line = rendered(&strip_line(&app, 200));
assert_eq!(app.flock_cpu_history().last().copied(), Some(300.0));
assert!(
line.contains("flock cpu 300.0%"),
"the figure must sum the same sheep the series does: {line:?}"
);
}
#[test]
fn host_memory_comes_before_the_flock_summary() {
let app = with_host(sample(), flock_of(4, 1));
let line = rendered(&strip_line(&app, 200));
assert!(
line.contains("0 errored · 0 parked"),
"summary dropped even at 200 columns: {line:?}"
);
assert!(
line.find("host mem").unwrap() < line.find("errored").unwrap(),
"the line reads machine, then flock: {line:?}"
);
assert!(
line.find("errored").unwrap() < line.find("flock cpu").unwrap(),
"the summary still leads the flock's own numbers: {line:?}"
);
}
}