use std::time::Duration;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use serde_json::{Map, Value};
use shep_core::protocol::SheepConfigView;
use super::super::app::{App, CPU_CEILING_FLOOR, HISTORY, RowKey};
use super::super::field::{Field, FieldSet};
use super::super::pane;
use super::super::pane_bleats::{BleatsPane, Filters, MatchKind};
use super::super::pane_sheep::{SheepPane, scale_top, window};
use super::super::tail::{Stream, TailLine};
use super::super::theme::Palette;
use super::detail::chip_text;
use super::flock::{self, fit};
use super::{cell, detail};
const GUTTER: usize = 8;
const MARGIN: usize = 12;
const CPU_HEADER_ROW: u16 = 1;
const CPU_CHART_ROW: u16 = 2;
const CPU_ROWS: usize = 8;
const MEM_HEADER_ROW: u16 = 10;
const MEM_CHART_ROW: u16 = 11;
const MEM_ROWS: usize = 5;
const AXIS_ROW: u16 = 16;
const HAIRLINE_ROW: u16 = AXIS_ROW + 1;
const TERMINAL_OVERHEAD: u16 = 2;
const MIN_HEIGHT_FOR_CHARTS: u16 = COLUMN_HEADER_ROW + 1 + TERMINAL_OVERHEAD;
const FULL_TIER_MIN_HEIGHT: u16 = 26;
const COLUMN_HEADER_ROW: u16 = 18;
const COLUMN_FIRST_ROW: u16 = 19;
const COLUMN_LAST_ROW: u16 = 44;
pub(crate) const COLUMN_BODY_ROWS: usize = (COLUMN_LAST_ROW - COLUMN_FIRST_ROW + 1) as usize;
const COLUMN_WIDTH: u16 = 76;
const COLUMN_NAME_W: u16 = 26;
#[cfg(test)]
const MIN_HEIGHT_FOR_COLUMN: u16 = COLUMN_LAST_ROW + 1;
fn column_top_row(height: u16) -> u16 {
if height > COLUMN_HEADER_ROW {
COLUMN_HEADER_ROW
} else {
1
}
}
const DIVIDER_COL: u16 = COLUMN_WIDTH;
const FEED_X: u16 = DIVIDER_COL + 1;
const FEED_WIDTH: u16 = 83;
fn column_body(view: &SheepConfigView, palette: Palette) -> (Vec<Line<'static>>, Vec<String>) {
let (fields, values) = pane::sheep_fields(&view.config);
let mut lines = Vec::new();
let mut groups = Vec::new();
let mut current: Option<&str> = None;
for field in fields.fields() {
if field.key == "env" {
continue;
}
if field.group.as_deref() != current {
current = field.group.as_deref();
let label = current.unwrap_or_default().to_owned();
push_group_header(&mut lines, &label, palette);
groups.push(label);
}
let pending = view.pending.iter().any(|key| key == &field.key);
let overridden = view.overridden.iter().any(|key| key == &field.key);
lines.push(field_row_line(
&fields, field, &values, pending, overridden, palette,
));
}
push_group_header(&mut lines, "env", palette);
for key in &view.env_keys {
let sealed = view.env_secrets.iter().any(|secret| secret == key);
lines.push(env_row_line(key, sealed, palette));
}
(lines, groups)
}
fn push_group_header(lines: &mut Vec<Line<'static>>, label: &str, palette: Palette) {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"\u{2500}".repeat(usize::from(COLUMN_WIDTH)),
palette.muted(),
)));
lines.push(Line::from(Span::styled(label.to_owned(), palette.muted())));
}
fn column_body_lines(view: &SheepConfigView, palette: Palette) -> Vec<Line<'static>> {
column_body(view, palette).0
}
fn waiting_line(palette: Palette) -> Line<'static> {
Line::from(Span::styled("reading config\u{2026}", palette.muted()))
}
#[must_use]
pub(crate) fn column_len(config: Option<&SheepConfigView>) -> usize {
match config {
Some(view) => column_body_lines(view, Palette::detect(None, None, None)).len(),
None => 1,
}
}
fn field_row_line(
fields: &FieldSet,
field: &Field,
values: &Map<String, Value>,
pending: bool,
overridden: bool,
palette: Palette,
) -> Line<'static> {
let raw = field_value_text(fields, field, values);
let flag = if pending {
"!"
} else if overridden {
"*"
} else {
""
};
let note = if pending { "awaits respawn" } else { "" };
let value_w = usize::from(COLUMN_WIDTH).saturating_sub(usize::from(COLUMN_NAME_W) + 2);
let left_w = value_w.saturating_sub(note.chars().count());
let key_style = if pending {
palette.attention()
} else {
palette.muted()
};
let value_style = if pending {
palette.attention()
} else if matches!(raw.as_str(), "(unset)" | "(default)") {
palette.muted()
} else {
Style::default()
};
let mut spans = vec![
Span::styled(
fit(&format!("{flag}{}", field.key), COLUMN_NAME_W),
key_style,
),
Span::raw(" "),
Span::styled(fit(&raw, left_w as u16), value_style),
];
if !note.is_empty() {
spans.push(Span::styled(note.to_owned(), palette.attention()));
}
Line::from(spans)
}
fn field_value_text(fields: &FieldSet, field: &Field, values: &Map<String, Value>) -> String {
let raw = match values.get(&field.key) {
None | Some(Value::Null) => return "(unset)".to_owned(),
Some(Value::String(text)) => text.clone(),
Some(Value::Bool(flag)) => flag.to_string(),
Some(Value::Number(number)) => number.to_string(),
Some(other) => other.to_string(),
};
if field.secret {
"<set>".to_owned()
} else if field.default.as_deref() == Some(raw.as_str()) {
"(default)".to_owned()
} else {
pane::resolved_display(fields, &field.key, &raw)
}
}
fn env_row_line(key: &str, sealed: bool, palette: Palette) -> Line<'static> {
if !sealed {
return Line::from(Span::raw(key.to_owned()));
}
let note = "edit in S";
let value_w = usize::from(COLUMN_WIDTH).saturating_sub(usize::from(COLUMN_NAME_W) + 2);
let left_w = value_w.saturating_sub(note.chars().count());
let mid = format!("{} sealed", "\u{2588}".repeat(8));
Line::from(vec![
Span::styled(fit(key, COLUMN_NAME_W), palette.muted()),
Span::raw(" "),
Span::styled(fit(&mid, left_w as u16), palette.attention()),
Span::styled(note.to_owned(), palette.muted()),
])
}
fn column_header_line(pane: &SheepPane, palette: Palette) -> Line<'static> {
let mut text = "\u{2588}\u{2588} CONFIG & ENV e edit".to_owned();
if let Some(pending) = pane
.config()
.map(|view| view.pending.len())
.filter(|count| *count > 0)
{
text.push_str(&format!(" {pending} pending"));
}
Line::from(Span::styled(text, palette.muted()))
}
fn draw_column(pane: &SheepPane, top: u16, area: Rect, buffer: &mut Buffer, palette: Palette) {
write_column_row(buffer, area, top, &column_header_line(pane, palette));
let body = match pane.config() {
Some(view) => column_body_lines(view, palette),
None => vec![waiting_line(palette)],
};
let offset = pane.view().offset();
for (i, line) in body.iter().skip(offset).take(COLUMN_BODY_ROWS).enumerate() {
write_column_row(buffer, area, top + 1 + i as u16, line);
}
}
fn write_column_row(buffer: &mut Buffer, area: Rect, row: u16, line: &Line<'static>) {
if row >= area.height {
return;
}
buffer.set_line(area.x, area.y + row, line, COLUMN_WIDTH);
}
fn draw_divider(top: u16, area: Rect, buffer: &mut Buffer, palette: Palette) {
let rule = Line::from(Span::styled("\u{2502}", palette.muted()));
for row in top..=(top + COLUMN_BODY_ROWS as u16) {
if row >= area.height {
break;
}
buffer.set_line(area.x + DIVIDER_COL, area.y + row, &rule, 1);
}
}
fn draw_feed(
app: &App,
pane: &SheepPane,
top: u16,
area: Rect,
buffer: &mut Buffer,
palette: Palette,
) {
let feed = pane.feed();
let tail = app.feed();
let levels = app.feed_classifier();
let survivors = feed.visible(&tail.lines, &levels);
let hidden = survivors.len().saturating_sub(COLUMN_BODY_ROWS);
write_feed_row(buffer, area, top, &feed_header_line(feed, hidden, palette));
for (i, line) in survivors.iter().skip(hidden).enumerate() {
write_feed_row(buffer, area, top + 1 + i as u16, &feed_line(line, palette));
}
}
fn feed_line(line: &TailLine, palette: Palette) -> Line<'static> {
let tag = match line.stream {
Stream::Out => "out",
Stream::Err => "err",
};
Line::from(vec![
Span::styled(format!("{tag} "), palette.muted()),
Span::raw(fit(&line.text, FEED_WIDTH.saturating_sub(5))),
])
}
fn feed_header_line(feed: &BleatsPane, hidden: usize, palette: Palette) -> Line<'static> {
let chip = chip_text("BLEATS");
let chip_width = u16::try_from(chip.chars().count() + 1).unwrap_or(FEED_WIDTH);
let budget = FEED_WIDTH.saturating_sub(chip_width);
Line::from(vec![
Span::styled(chip, palette.band(crate::vocabulary::Role::Butter)),
Span::raw(" "),
Span::styled(
fit(&feed_header_text(feed, hidden), budget),
palette.muted(),
),
])
}
fn feed_header_text(feed: &BleatsPane, hidden: usize) -> String {
let mut parts = vec!["out then err".to_string()];
if hidden > 0 {
parts.push(format!("{hidden} earlier"));
}
for chip in feed_chip_labels(feed.filters()) {
parts.push(format!("[{chip}]"));
}
parts.push("/ narrow".to_string());
parts.join(" \u{b7} ")
}
fn feed_chip_labels(filters: &Filters) -> Vec<String> {
let mut chips = Vec::new();
if let Some(stream) = filters.stream {
chips.push(match stream {
Stream::Out => "out only".to_string(),
Stream::Err => "err only".to_string(),
});
}
if let Some(min) = filters.min_level {
chips.push(format!("level\u{2265}{min}"));
}
if let Some(text) = filters.match_text() {
let suffix = filters.match_kind().map_or("", MatchKind::chip_suffix);
chips.push(format!("match {text}{suffix}"));
}
chips
}
fn write_feed_row(buffer: &mut Buffer, area: Rect, row: u16, line: &Line<'static>) {
if row >= area.height {
return;
}
buffer.set_line(area.x + FEED_X, area.y + row, line, FEED_WIDTH);
}
pub fn draw(app: &App, pane: &SheepPane, area: Rect, buffer: &mut Buffer) {
if area.height == 0 {
return;
}
let palette = app.palette();
let sheep_row = app.sheep_pane_row();
let mut spans = match sheep_row {
Some(row) => detail::identity_line(app, row, area.width, palette).spans,
None => {
let RowKey::Sheep(id) = pane.sheep() else {
unreachable!("a sheep pane is only ever opened on `RowKey::Sheep`")
};
vec![Span::styled(
format!("sheep {id}: it is no longer in the flock"),
palette.muted(),
)]
}
};
if let Some(pending) = pane
.config()
.map(|view| view.pending.len())
.filter(|count| *count > 0)
{
spans.push(Span::styled(
format!(" !{pending} pending"),
app.palette().attention(),
));
}
let line = Line::from(spans);
buffer.set_line(area.x, area.y, &line, area.width);
if let Some(row) = sheep_row {
let tier = chart_tier(area.width, area.height);
match tier {
ChartTier::Full => {
draw_charts(app, row.info.id, row.info.max_memory, area, buffer, palette);
}
ChartTier::CpuOnly => {
draw_cpu_only(app, row.info.id, row.info.max_memory, area, buffer, palette);
}
ChartTier::Sparkline => {
draw_sparkline_row(app, &row.info, area, buffer, palette);
}
ChartTier::None => {}
}
if matches!(tier, ChartTier::Full | ChartTier::CpuOnly) {
draw_hairline(area, buffer, palette);
}
}
let top = column_top_row(area.height);
if area.height > top {
draw_column(pane, top, area, buffer, palette);
}
if area.height > top && usize::from(area.width) >= usize::from(FEED_X + FEED_WIDTH) {
draw_divider(top, area, buffer, palette);
draw_feed(app, pane, top, area, buffer, palette);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChartTier {
Full,
CpuOnly,
Sparkline,
None,
}
fn chart_tier(width: u16, height: u16) -> ChartTier {
let terminal_height = height + TERMINAL_OVERHEAD;
if terminal_height < MIN_HEIGHT_FOR_CHARTS {
return ChartTier::None;
}
let by_width = if width >= 140 {
ChartTier::Full
} else if width >= 100 {
ChartTier::CpuOnly
} else if width >= flock::MIN_WIDTH {
ChartTier::Sparkline
} else {
ChartTier::None
};
if by_width == ChartTier::Full && terminal_height < FULL_TIER_MIN_HEIGHT {
ChartTier::CpuOnly
} else {
by_width
}
}
fn draw_charts(
app: &App,
sheep_id: u32,
max_memory: Option<u64>,
area: Rect,
buffer: &mut Buffer,
palette: Palette,
) {
let body_cells = chart_body_cells(area.width);
let cpu_history = app.cpu_history(sheep_id);
let rss_history = app.rss_history(sheep_id);
let cpu_header = cpu_header_text(cpu_history.len(), body_cells);
write_row(
buffer,
area,
CPU_HEADER_ROW,
&format!("\u{2588}\u{2588} CPU {cpu_header}"),
palette.muted(),
);
for (i, row_text) in cpu_chart_rows(cpu_history, body_cells)
.into_iter()
.enumerate()
{
write_row(
buffer,
area,
CPU_CHART_ROW + i as u16,
&row_text,
Style::default(),
);
}
let mem_window = &rss_history[rss_history.len().saturating_sub(body_cells)..];
let mem_window_peak = mem_window.iter().copied().max().unwrap_or(0);
let mem_header = mem_header_text(max_memory, mem_window_peak);
write_row(
buffer,
area,
MEM_HEADER_ROW,
&format!("\u{2588}\u{2588} MEM {mem_header}"),
palette.muted(),
);
let (mem_rows, marked) = mem_chart_rows(rss_history, max_memory, body_cells);
for (i, row_text) in mem_rows.into_iter().enumerate() {
let style = if marked == Some(i) {
palette.attention()
} else {
Style::default()
};
write_row(buffer, area, MEM_CHART_ROW + i as u16, &row_text, style);
}
write_row(
buffer,
area,
AXIS_ROW,
&axis_row(body_cells),
palette.muted(),
);
}
fn draw_cpu_only(
app: &App,
sheep_id: u32,
max_memory: Option<u64>,
area: Rect,
buffer: &mut Buffer,
palette: Palette,
) {
let body_cells = chart_body_cells(area.width);
let cpu_history = app.cpu_history(sheep_id);
let rss_history = app.rss_history(sheep_id);
let cpu_header = cpu_header_text(cpu_history.len(), body_cells);
write_row(
buffer,
area,
CPU_HEADER_ROW,
&format!("\u{2588}\u{2588} CPU {cpu_header}"),
palette.muted(),
);
for (i, row_text) in cpu_chart_rows(cpu_history, body_cells)
.into_iter()
.enumerate()
{
write_row(
buffer,
area,
CPU_CHART_ROW + i as u16,
&row_text,
Style::default(),
);
}
let current_rss = rss_history.last().copied().unwrap_or(0);
write_row(
buffer,
area,
MEM_HEADER_ROW,
&mem_line_text(current_rss, max_memory),
Style::default(),
);
write_row(
buffer,
area,
AXIS_ROW,
&axis_row(body_cells),
palette.muted(),
);
}
fn mem_line_text(current_rss: u64, max_memory: Option<u64>) -> String {
let gauge = cell::gauge(current_rss, max_memory, 10);
match max_memory {
Some(limit) => format!(
"rss {} of {} {gauge}",
crate::output::human_bytes(current_rss),
crate::output::human_bytes(limit),
),
None => format!("rss {} {gauge}", crate::output::human_bytes(current_rss)),
}
}
fn draw_hairline(area: Rect, buffer: &mut Buffer, palette: Palette) {
write_row(
buffer,
area,
HAIRLINE_ROW,
&cell::rule(usize::from(area.width)),
palette.muted(),
);
}
fn draw_sparkline_row(
app: &App,
info: &shep_core::protocol::ProcessInfo,
area: Rect,
buffer: &mut Buffer,
palette: Palette,
) {
let spark = cell::sparkline(app.cpu_history(info.id), 10, app.cpu_ceiling());
let gauge = cell::gauge(info.memory_bytes.unwrap_or(0), info.max_memory, 10);
write_row(
buffer,
area,
CPU_HEADER_ROW,
&format!("CPU 20s {spark} MEM/CEIL {gauge}"),
palette.muted(),
);
}
fn write_row(buffer: &mut Buffer, area: Rect, row: u16, text: &str, style: Style) {
let line = Line::from(Span::styled(text.to_string(), style));
buffer.set_line(area.x, area.y + row, &line, area.width);
}
fn chart_body_cells(width: u16) -> usize {
usize::from(width)
.saturating_sub(GUTTER + MARGIN)
.min(HISTORY)
}
fn window_label(d: Duration) -> String {
let secs = d.as_secs();
format!("{}m{:02}s", secs / 60, secs % 60)
}
fn cpu_header_text(history_len: usize, body_cells: usize) -> String {
let full = window_label(window(body_cells));
if history_len < body_cells {
format!(
"collecting \u{b7} {} of {full}",
window_label(window(history_len))
)
} else {
format!("{full}, one 2s sample per column")
}
}
fn cpu_chart_rows(history: &[f32], body_cells: usize) -> Vec<String> {
let window_slice = &history[history.len().saturating_sub(body_cells)..];
let peak = window_slice.iter().copied().fold(0.0_f32, f32::max);
let ceiling = scale_top(f64::from(peak), f64::from(CPU_CEILING_FLOOR));
let bars = cell::chart(history, ceiling as f32, body_cells, CPU_ROWS);
gutter_lines(bars, ceiling, GutterCadence::Alternating, |value| {
format!("{value:.0}%")
})
}
fn mem_header_text(max_memory: Option<u64>, window_peak: u64) -> String {
match max_memory {
Some(limit) => format!(
"rss same window \u{b7} ceiling {}",
crate::output::human_bytes(limit)
),
None => format!(
"rss same window \u{b7} no limit set \u{b7} scaled to peak {}",
crate::output::human_bytes(window_peak)
),
}
}
fn mem_chart_rows(
history: &[u64],
max_memory: Option<u64>,
body_cells: usize,
) -> (Vec<String>, Option<usize>) {
let window_slice = &history[history.len().saturating_sub(body_cells)..];
let window_peak = window_slice.iter().copied().max().unwrap_or(0);
let peak_for_scale = max_memory.map_or(window_peak, |limit| limit.max(window_peak));
const MIB: f64 = (1u64 << 20) as f64;
let ceiling_mib = scale_top(peak_for_scale as f64 / MIB, 0.0);
let ceiling = ceiling_mib * MIB;
let samples: Vec<f32> = history.iter().map(|&bytes| bytes as f32).collect();
let bars = cell::chart(&samples, ceiling as f32, body_cells, MEM_ROWS);
let mut lines = gutter_lines(bars, ceiling, GutterCadence::EveryRow, |value| {
crate::output::human_bytes(value as u64)
});
let marked = max_memory.map(|limit| {
if ceiling <= 0.0 {
return 0;
}
let band = ceiling / MEM_ROWS as f64;
((ceiling - limit as f64) / band)
.round()
.clamp(0.0, (MEM_ROWS - 1) as f64) as usize
});
if let Some(row) = marked {
let limit = max_memory.unwrap_or_default();
let label = format!("{:>7} ", crate::output::human_bytes(limit));
lines[row] = format!("{label}{} ceiling", "\u{254c}".repeat(body_cells));
}
(lines, marked)
}
#[derive(Clone, Copy)]
enum GutterCadence {
Alternating,
EveryRow,
}
fn gutter_lines(
bars: Vec<String>,
ceiling: f64,
cadence: GutterCadence,
format_value: impl Fn(f64) -> String,
) -> Vec<String> {
let rows = bars.len();
let last = rows.saturating_sub(1);
bars.into_iter()
.enumerate()
.map(|(i, bar)| {
let labelled = match cadence {
GutterCadence::Alternating => i % 2 == 0,
GutterCadence::EveryRow => true,
};
let gutter = if i == last {
format!("{:>7} ", "0")
} else if labelled {
#[allow(clippy::cast_precision_loss)] let value = ceiling * (rows - i) as f64 / rows as f64;
format!("{:>7} ", format_value(value))
} else {
format!("{:>7} ", "|")
};
format!("{gutter}{bar}")
})
.collect()
}
fn axis_row(body_cells: usize) -> String {
let label = "now";
let dashes = body_cells.saturating_sub(label.len());
format!("{}{}{label}", " ".repeat(GUTTER), "\u{2500}".repeat(dashes))
}
#[cfg(test)]
mod tests {
use super::super::super::level::Classifier;
use super::super::super::tail::Tail;
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use shep_core::protocol::Response;
use super::super::super::app::{Body, Control, KeyPress, Msg, Sent};
use super::super::super::frames::render_text;
use super::super::super::level::Level;
use super::super::fixtures;
use super::*;
#[test]
fn the_band_does_not_draw_the_sheep_that_replaced_the_pinned_one() {
let mut app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "alpha", ProcStatus::Online).build(),
ProcessInfo::builder(2, "bravo", ProcStatus::Online).build(),
],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(
matches!(app.body(), Body::Sheep(_)),
"setup: the pane opened on alpha"
);
let _ = app.update(Msg::Snapshot {
rows: vec![ProcessInfo::builder(2, "bravo", ProcStatus::Online).build()],
at: std::time::Instant::now(),
});
assert_eq!(
app.selected(),
Some(RowKey::Sheep(2)),
"setup: the reseat moved the selection to bravo"
);
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, 80, 3);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
let text = render_text(&buffer);
assert!(
!text.contains("bravo"),
"must not draw the sheep that replaced the pinned one: {text:?}"
);
assert!(
text.contains("sheep 1: it is no longer in the flock"),
"got {text:?}"
);
}
#[test]
fn the_charts_do_not_draw_the_sheep_that_replaced_the_pinned_one() {
let mut app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "alpha", ProcStatus::Online).build(),
ProcessInfo::builder(2, "bravo", ProcStatus::Online)
.max_memory(Some(64 << 20))
.build(),
],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(
matches!(app.body(), Body::Sheep(_)),
"setup: the pane opened on alpha"
);
let _ = app.update(Msg::Snapshot {
rows: vec![
ProcessInfo::builder(2, "bravo", ProcStatus::Online)
.max_memory(Some(64 << 20))
.build(),
],
at: std::time::Instant::now(),
});
assert_eq!(
app.selected(),
Some(RowKey::Sheep(2)),
"setup: the reseat moved the selection to bravo"
);
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, 40, MIN_HEIGHT_FOR_CHARTS);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
let text = render_text(&buffer);
assert!(
!text.contains("ceiling"),
"bravo's own max_memory must not draw once the pinned sheep is \
gone: {text:?}"
);
}
fn header_at(width: u16) -> String {
let body = chart_body_cells(width);
cpu_header_text(body, body)
}
fn header_with_samples(history_len: usize, body_cells: usize) -> String {
cpu_header_text(history_len, body_cells)
}
fn mem_rows_with_limit(max_memory: Option<u64>) -> (Vec<String>, Option<usize>) {
let history = [20 << 20, 25 << 20, 30 << 20, 40 << 20, 48 << 20];
mem_chart_rows(&history, max_memory, 20)
}
fn mem_header_with_limit(max_memory: Option<u64>, window_peak: u64) -> String {
mem_header_text(max_memory, window_peak)
}
#[test]
fn the_cpu_header_states_the_drawn_window() {
assert!(header_at(160).contains("4m40s, one 2s sample per column"));
assert!(header_at(140).contains("4m00s, one 2s sample per column"));
}
#[test]
fn a_partial_buffer_says_how_much_it_has() {
assert!(header_with_samples(35, 140).contains("collecting \u{b7} 1m10s of 4m40s"));
}
#[test]
fn the_memory_chart_labels_a_real_ceiling() {
let (rows, marked) = mem_rows_with_limit(Some(52 << 20));
assert_eq!(
marked,
Some(2),
"a 52M limit against this fixture's 48M peak scales to a 100M \
ceiling, 20M per row, so the marked row is nearest at 2, not \
the top"
);
assert!(rows[2].contains("ceiling"), "got {rows:?}");
assert!(
rows[2].contains("52.0M"),
"the marked row states the limit's own value, not the ladder \
row (60.0M) it happens to land nearest: {:?}",
rows[2]
);
}
#[test]
fn the_marked_row_is_the_nearest_one_not_the_floor() {
let history = [10 << 20; 20];
let (rows, marked) = mem_chart_rows(&history, Some(64 << 20), 20);
assert_eq!(marked, Some(2), "60.0M is nearer 64M than 80.0M is");
assert!(rows[2].contains("64.0M"), "got {:?}", rows[2]);
}
#[test]
fn the_memory_chart_states_its_substitute_denominator() {
let header = mem_header_with_limit(None, 48 << 20);
assert!(header.contains("no limit set"));
assert!(header.contains("scaled to peak"));
assert!(
!mem_rows_with_limit(None)
.0
.iter()
.any(|row| row.contains("ceiling"))
);
}
#[test]
fn chart_body_stays_within_the_history_buffer() {
let body = chart_body_cells(300);
assert_eq!(body, HISTORY);
assert!(
!cpu_header_text(HISTORY, body).contains("collecting"),
"a full buffer past the cap must not still read collecting"
);
}
#[test]
fn the_gutter_labels_more_than_the_ends() {
let history = [10.0_f32, 20.0, 30.0, 40.0, 45.0];
let rows = cpu_chart_rows(&history, 20);
let labelled: Vec<&str> = rows
.iter()
.map(|row| row[..GUTTER].trim())
.filter(|cell| !cell.is_empty())
.collect();
assert!(
labelled.len() > 2,
"expected more than just the top and bottom label: {rows:?}"
);
assert!(
rows.iter().any(|row| row[..GUTTER].contains('|')),
"expected a bare tick between labels: {rows:?}"
);
}
#[test]
fn the_gutter_cadence_differs_by_chart() {
let (mem_rows, _) = mem_rows_with_limit(None);
let labelled_mem = mem_rows
.iter()
.filter(|row| {
let cell = row[..GUTTER].trim();
!cell.is_empty() && cell != "|"
})
.count();
assert_eq!(
labelled_mem, MEM_ROWS,
"every memory row should carry a label: {mem_rows:?}"
);
let cpu_history = [10.0_f32, 20.0, 30.0, 40.0, 45.0];
let cpu_rows = cpu_chart_rows(&cpu_history, 20);
let ticked_cpu = cpu_rows
.iter()
.filter(|row| row[..GUTTER].contains('|'))
.count();
assert!(
ticked_cpu > 0,
"the CPU gutter should still alternate with bare ticks: {cpu_rows:?}"
);
assert!(
ticked_cpu < CPU_ROWS,
"the CPU gutter should not label every row: {cpu_rows:?}"
);
}
fn app_with_two_polls(id: u32, name: &str, max_memory: Option<u64>) -> App {
let t0 = std::time::Instant::now();
let mut app = App::new(
fixtures::plain(),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![
ProcessInfo::builder(id, name, ProcStatus::Online)
.cpu_ms(Some(0))
.memory_bytes(Some(10 << 20))
.max_memory(max_memory)
.build(),
],
at: t0,
});
let t1 = t0 + Duration::from_secs(2);
app.update(Msg::Snapshot {
rows: vec![
ProcessInfo::builder(id, name, ProcStatus::Online)
.cpu_ms(Some(2000))
.memory_bytes(Some(10 << 20))
.max_memory(max_memory)
.build(),
],
at: t1,
});
app
}
#[test]
fn the_two_charts_share_one_body_width() {
let app = app_with_two_polls(1, "alpha", None);
let area = Rect::new(0, 0, 40, MIN_HEIGHT_FOR_CHARTS);
let mut buffer = Buffer::empty(area);
draw_charts(&app, 1, None, area, &mut buffer, fixtures::plain());
let text = render_text(&buffer);
let lines: Vec<&str> = text.lines().collect();
let cpu_last_row = lines[usize::from(CPU_CHART_ROW) + CPU_ROWS - 1];
let mem_last_row = lines[usize::from(MEM_CHART_ROW) + MEM_ROWS - 1];
let cpu_end = cpu_last_row.trim_end().chars().count();
let mem_end = mem_last_row.trim_end().chars().count();
assert_eq!(
cpu_end, mem_end,
"CPU chart's bottom row ends at column {cpu_end}, memory's at \
{mem_end}: {cpu_last_row:?} vs {mem_last_row:?}"
);
}
use shep_core::config::AppConfig;
fn web_view() -> SheepConfigView {
let mut config = AppConfig {
name: "web".to_owned(),
..AppConfig::default()
};
config
.env
.insert("DB_HOST".to_owned(), "db.internal".to_owned());
config
.env
.insert("API_KEY".to_owned(), "{{secret:API}}".to_owned());
SheepConfigView::new(config, Vec::new(), vec!["max_memory".to_owned()])
}
fn web_view_overridden() -> SheepConfigView {
let config = AppConfig {
name: "web".to_owned(),
..AppConfig::default()
};
SheepConfigView::new(config, vec!["max_memory".to_owned()], Vec::new())
}
fn view_with_env(pairs: &[(&str, &str)]) -> SheepConfigView {
let mut config = AppConfig {
name: "test".to_owned(),
..AppConfig::default()
};
for (key, value) in pairs {
config.env.insert((*key).to_owned(), (*value).to_owned());
}
SheepConfigView::new(config, Vec::new(), Vec::new())
}
fn line_text(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn group_labels_of(view: &SheepConfigView) -> Vec<String> {
column_body(view, fixtures::plain()).1
}
fn field_row_of(view: &SheepConfigView, key: &str) -> String {
column_body_lines(view, fixtures::plain())
.iter()
.map(line_text)
.find(|line| line.trim_start_matches(['!', '*']).starts_with(key))
.unwrap_or_else(|| panic!("no row for {key}"))
}
fn env_rows_of(view: &SheepConfigView) -> Vec<String> {
let lines: Vec<String> = column_body_lines(view, fixtures::plain())
.iter()
.map(line_text)
.collect();
let label = lines
.iter()
.position(|line| line.trim() == "env")
.expect("column_body always closes with an env label");
lines[label + 1..].to_vec()
}
fn row_for<'a>(key: &str, rows: &'a [String]) -> &'a str {
rows.iter()
.find(|row| row.contains(key))
.unwrap_or_else(|| panic!("no row for {key} in {rows:?}"))
}
fn column_of(config: Option<SheepConfigView>) -> String {
let lines = match &config {
Some(view) => column_body_lines(view, fixtures::plain()),
None => vec![waiting_line(fixtures::plain())],
};
lines.iter().map(line_text).collect::<Vec<_>>().join("\n")
}
fn config_pane_group_labels(view: &SheepConfigView) -> Vec<String> {
let pane = crate::lookout::pane::ConfigPane::sheep(view.clone());
let mut labels = Vec::new();
let mut current: Option<&str> = None;
for field in pane.fields().fields() {
if field.group.as_deref() != current {
current = field.group.as_deref();
labels.push(current.unwrap_or_default().to_owned());
}
}
labels
}
#[test]
fn the_groups_are_the_schemas_eight_in_its_own_order() {
let view = web_view();
assert_eq!(group_labels_of(&view), config_pane_group_labels(&view));
}
#[test]
fn a_pending_field_is_marked_and_annotated() {
let row = field_row_of(&web_view(), "max_memory");
assert!(row.starts_with('!'), "{row:?}");
assert!(row.contains("awaits respawn"), "{row:?}");
}
#[test]
fn an_overridden_field_is_marked_with_the_editing_panes_own_glyph() {
let row = field_row_of(&web_view_overridden(), "max_memory");
assert!(row.starts_with('*'), "{row:?}");
assert!(
!row.contains("awaits respawn"),
"overridden is not pending: {row:?}"
);
let other_row = field_row_of(&web_view_overridden(), "restart_delay");
assert!(
!other_row.starts_with('*') && !other_row.starts_with('!'),
"an unrelated field must not pick up the marker: {other_row:?}"
);
}
#[test]
fn no_env_value_reaches_the_column() {
let rendered = env_rows_of(&web_view()).join("");
assert!(!rendered.contains("hunter2"), "{rendered:?}");
}
#[test]
fn a_sealed_key_is_marked_and_a_plain_one_is_not() {
let rows = env_rows_of(&view_with_env(&[
("PLAIN", "v"),
("SEALED", "{{secret:PW}}"),
]));
assert!(row_for("SEALED", &rows).contains("sealed"));
assert!(!row_for("PLAIN", &rows).contains("sealed"));
}
#[test]
fn a_pane_without_its_config_yet_says_so() {
assert!(column_of(None).contains("reading config"));
}
#[test]
fn the_longest_pending_field_name_is_not_truncated() {
let config = AppConfig {
name: "web".to_owned(),
..AppConfig::default()
};
let view = SheepConfigView::new(
config,
Vec::new(),
vec!["exp_backoff_restart_delay".to_owned()],
);
let row = field_row_of(&view, "exp_backoff_restart_delay");
assert!(
row.starts_with("!exp_backoff_restart_delay "),
"the flagged name and its separator must survive whole: {row:?}"
);
}
#[test]
fn the_column_and_the_editing_pane_resolve_the_same_mem_size_units() {
use shep_core::values::MemSize;
let mut config = AppConfig {
name: "web".to_owned(),
..AppConfig::default()
};
config.max_memory = Some(MemSize::from_bytes(1_234_567));
let column_view = SheepConfigView::new(config.clone(), Vec::new(), Vec::new());
let editing_view = SheepConfigView::new(config, Vec::new(), Vec::new());
let row = field_row_of(&column_view, "max_memory");
let pane = crate::lookout::pane::ConfigPane::sheep(editing_view);
let resolved = pane.display_value("max_memory");
assert!(
resolved.ends_with(" B"),
"setup: the fixture must actually exercise the unit suffix: {resolved:?}"
);
assert!(
row.contains(&resolved),
"column row {row:?} does not carry the editing pane's own {resolved:?}"
);
}
#[test]
fn a_secret_field_renders_set_and_never_its_value() {
use crate::lookout::field::FieldKind;
let field = Field {
key: "webhook".to_owned(),
help: String::new(),
group: None,
kind: FieldKind::Text,
value_kind: None,
default: None,
default_value: None,
secret: true,
editable: true,
example: None,
accepts: Vec::new(),
refuses: Vec::new(),
neighbours: Vec::new(),
};
let fields = FieldSet::from_fields(vec![field.clone()], &[]);
let mut values = Map::new();
values.insert("webhook".to_owned(), Value::String("hunter2".to_owned()));
assert_eq!(field_value_text(&fields, &field, &values), "<set>");
}
#[test]
fn the_column_does_not_draw_the_sheep_that_replaced_the_pinned_one() {
let mut app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "alpha", ProcStatus::Online).build(),
ProcessInfo::builder(2, "bravo", ProcStatus::Online).build(),
],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(
matches!(app.body(), Body::Sheep(_)),
"setup: the pane opened on alpha"
);
let alpha_config = AppConfig {
name: "alpha".to_owned(),
..AppConfig::default()
};
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "alpha".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
alpha_config,
Vec::new(),
Vec::new(),
)))),
});
let _ = app.update(Msg::Snapshot {
rows: vec![ProcessInfo::builder(2, "bravo", ProcStatus::Online).build()],
at: std::time::Instant::now(),
});
assert_eq!(
app.selected(),
Some(RowKey::Sheep(2)),
"setup: the reseat moved the selection to bravo"
);
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, 80, MIN_HEIGHT_FOR_COLUMN);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
let text = render_text(&buffer);
assert!(
text.contains("name alpha"),
"must still draw the pinned sheep's own `name` field: {text:?}"
);
assert!(!text.contains("bravo"), "{text:?}");
}
#[test]
fn the_feed_does_not_draw_the_sheep_that_replaced_the_pinned_one() {
let mut app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "alpha", ProcStatus::Online).build(),
ProcessInfo::builder(2, "bravo", ProcStatus::Online).build(),
],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(
matches!(app.body(), Body::Sheep(_)),
"setup: the pane opened on alpha"
);
app.update(Msg::Bleats {
tail: Tail {
lines: vec![TailLine {
stream: Stream::Out,
text: "alpha wrote this".to_string(),
}],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: None,
},
});
let _ = app.update(Msg::Snapshot {
rows: vec![ProcessInfo::builder(2, "bravo", ProcStatus::Online).build()],
at: std::time::Instant::now(),
});
assert_eq!(
app.selected(),
Some(RowKey::Sheep(2)),
"setup: the reseat moved the selection to bravo"
);
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, FEED_X + FEED_WIDTH, MIN_HEIGHT_FOR_COLUMN);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
let text = render_text(&buffer);
assert!(
text.contains("alpha wrote this"),
"the pinned sheep's own line must still draw: {text:?}"
);
}
#[test]
fn the_headers_level_chip_names_the_minimum() {
let mut feed = BleatsPane::new(RowKey::Sheep(1));
feed.set_min_level(Some(Level::Warn));
let text = feed_header_text(&feed, 0);
assert!(text.contains("[level≥warn]"), "got {text:?}");
assert!(text.contains("/ narrow"), "got {text:?}");
}
#[test]
fn the_headers_match_chip_names_a_regex() {
let mut feed = BleatsPane::new(RowKey::Sheep(1));
feed.set_match("/po+l/".to_string());
let text = feed_header_text(&feed, 0);
assert!(text.contains("match /po+l/ (regex)"), "got {text:?}");
}
#[test]
fn the_headers_match_chip_carries_nothing_after_a_literal() {
let mut feed = BleatsPane::new(RowKey::Sheep(1));
feed.set_match("pool".to_string());
let text = feed_header_text(&feed, 0);
assert!(text.contains("[match pool]"), "got {text:?}");
}
#[test]
fn the_headers_match_chip_names_an_invalid_regex() {
let mut feed = BleatsPane::new(RowKey::Sheep(1));
feed.set_match("/pool(/".to_string());
let text = feed_header_text(&feed, 0);
assert!(
text.contains("invalid regex, matches nothing"),
"got {text:?}"
);
}
#[test]
fn the_headers_earlier_clause_counts_hidden_survivors_not_raw_lines() {
let feed = BleatsPane::new(RowKey::Sheep(1));
let tail = Tail {
lines: (0..COLUMN_BODY_ROWS + 1)
.map(|n| TailLine {
stream: Stream::Out,
text: format!("line-{n}"),
})
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: None,
};
let hidden = feed
.visible(&tail.lines, &Classifier::new(&[]))
.len()
.saturating_sub(COLUMN_BODY_ROWS);
let text = feed_header_text(&feed, hidden);
assert!(text.contains("1 earlier"), "got {text:?}");
}
#[test]
fn the_feed_shows_the_newest_lines_and_truncates_a_long_one() {
let mut app = fixtures::app_with(
vec![ProcessInfo::builder(1, "alpha", ProcStatus::Online).build()],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Bleats {
tail: Tail {
lines: (0..COLUMN_BODY_ROWS + 5)
.map(|n| TailLine {
stream: Stream::Out,
text: format!("line-{n}"),
})
.chain(std::iter::once(TailLine {
stream: Stream::Err,
text: "x".repeat(200),
}))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: None,
},
});
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, FEED_X + FEED_WIDTH, MIN_HEIGHT_FOR_COLUMN);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
let text = render_text(&buffer);
assert!(
!text.contains("line-0\n") && !text.contains("line-0 "),
"the oldest lines scroll off the top: {text:?}"
);
assert!(
text.contains(&format!("{}\u{2026}", "x".repeat(77))),
"the long line truncates with an ellipsis at the column's own \
width (78 cells: 77 characters plus the ellipsis), not before \
it and not after: {text:?}"
);
assert!(
!text.contains(&"x".repeat(78)),
"and no further: a truncated line, not a wrapped one: {text:?}"
);
}
#[test]
fn the_divider_runs_the_full_height_of_the_column_and_feed() {
let mut app = fixtures::app_with(
vec![ProcessInfo::builder(1, "alpha", ProcStatus::Online).build()],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, FEED_X + FEED_WIDTH, MIN_HEIGHT_FOR_COLUMN);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
for row in COLUMN_HEADER_ROW..=COLUMN_LAST_ROW {
assert_eq!(
buffer[(COLUMN_WIDTH, row)].symbol(),
"\u{2502}",
"row {row} has no divider"
);
}
}
#[test]
fn the_chart_body_is_the_width_less_its_gutter_and_margin() {
assert_eq!(chart_body_cells(160), 140);
assert_eq!(chart_body_cells(140), 120);
}
#[test]
fn a_wider_terminal_grows_the_margin_rather_than_the_body() {
assert_eq!(chart_body_cells(200), 140);
}
fn render_at(width: u16, height: u16) -> String {
let t0 = std::time::Instant::now();
let mut app = App::new(
fixtures::plain(),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![
ProcessInfo::builder(7, "web", ProcStatus::Online)
.cpu_ms(Some(0))
.memory_bytes(Some(10 << 20))
.max_memory(Some(64 << 20))
.build(),
],
at: t0,
});
let _ = app.update(Msg::Key(KeyPress::Confirm));
for i in 1..6u64 {
let at = t0 + Duration::from_secs(2 * i);
app.update(Msg::Snapshot {
rows: vec![
ProcessInfo::builder(7, "web", ProcStatus::Online)
.cpu_ms(Some(i * 400))
.memory_bytes(Some((10 + i * 4) << 20))
.max_memory(Some(64 << 20))
.build(),
],
at,
});
}
let Body::Sheep(pane) = app.body() else {
panic!("setup: the pane opened on web")
};
let area = Rect::new(0, 0, width, height);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
render_text(&buffer)
}
#[test]
fn below_a_hundred_and_forty_columns_only_the_cpu_chart_draws() {
let rendered = render_at(139, 48);
assert!(
rendered.contains("\u{2588}\u{2588} CPU"),
"got {rendered:?}"
);
assert!(rendered.contains("rss "), "got {rendered:?}");
assert!(
!rendered.contains("\u{2588}\u{2588} MEM"),
"got {rendered:?}"
);
}
#[test]
fn below_a_hundred_columns_both_charts_become_the_sparkline_pair() {
let rendered = render_at(99, 48);
assert!(
!rendered.contains("\u{2588}\u{2588} CPU"),
"got {rendered:?}"
);
assert!(
!rendered.contains("\u{2588}\u{2588} MEM"),
"got {rendered:?}"
);
assert!(rendered.contains("CPU 20s"), "got {rendered:?}");
}
#[test]
fn the_hairline_and_column_headers_sit_at_the_spec_s_own_rows() {
let rendered = render_at(160, 48);
let lines: Vec<&str> = rendered.lines().collect();
assert!(
lines[16].contains("now"),
"row 16 (spec 17): {:?}",
lines.get(16)
);
let hairline = lines[17].trim_end();
assert!(
!hairline.is_empty() && hairline.chars().all(|c| c == '\u{2500}'),
"row 17 (spec 18) is not a hairline rule: {:?}",
lines.get(17)
);
assert!(
lines[18].contains("\u{2588}\u{2588} CONFIG & ENV"),
"row 18 (spec 19): {:?}",
lines.get(18)
);
}
#[test]
fn a_short_terminal_drops_the_charts_before_the_columns() {
assert!(!render_at(160, 20).contains("\u{2588}\u{2588} MEM"));
assert!(render_at(160, 20).contains("\u{2588}\u{2588} CONFIG & ENV"));
assert!(!render_at(160, 18).contains("\u{2588}\u{2588} CPU"));
assert!(render_at(160, 18).contains("\u{2588}\u{2588} CONFIG & ENV"));
}
#[test]
fn at_a_hundred_and_forty_columns_both_charts_still_draw() {
let rendered = render_at(140, 48);
assert!(
rendered.contains("\u{2588}\u{2588} CPU"),
"got {rendered:?}"
);
assert!(
rendered.contains("\u{2588}\u{2588} MEM"),
"got {rendered:?}"
);
}
#[test]
fn at_a_hundred_columns_the_cpu_chart_still_draws() {
let rendered = render_at(100, 48);
assert!(
rendered.contains("\u{2588}\u{2588} CPU"),
"got {rendered:?}"
);
assert!(!rendered.contains("CPU 20s"), "got {rendered:?}");
}
#[test]
fn at_the_chart_height_floor_a_chart_still_draws() {
let rendered = render_at(160, MIN_HEIGHT_FOR_CHARTS - TERMINAL_OVERHEAD);
assert!(
rendered.contains("\u{2588}\u{2588} CPU"),
"got {rendered:?}"
);
}
#[test]
fn at_the_full_tier_height_floor_the_memory_chart_still_draws() {
let rendered = render_at(160, FULL_TIER_MIN_HEIGHT - TERMINAL_OVERHEAD);
assert!(
rendered.contains("\u{2588}\u{2588} MEM"),
"got {rendered:?}"
);
}
}