use ratatui::style::Style;
use ratatui::text::{Line, Span};
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use super::super::super::app::{App, GroupTotals, Row, RowKey};
use super::super::super::theme::Palette;
use super::super::cell;
use super::columns::{Column, name_width};
use super::facts::FrameFacts;
use super::layout::{fit_owned, pad_ground};
use crate::output::{cfg_cell, exit_cell, human_bytes, human_duration};
#[must_use]
pub fn key_line(
app: &App,
facts: &FrameFacts<'_>,
key: &RowKey,
columns: &[Column],
width: u16,
selected: bool,
) -> Line<'static> {
match key {
RowKey::Sheep(id) => app.row(*id).map_or_else(
|| Line::from(Span::raw(" ".repeat(usize::from(width)))),
|row| row_line(app, facts, row, columns, width, selected),
),
RowKey::Group(name) => group_line(app, name, columns, width, selected),
RowKey::Section(label) => section_line(label, width, app.data_palette().muted()),
RowKey::Fold(_) => unreachable!("flat view emits no fold header"),
}
}
pub(super) fn section_line(label: &str, width: u16, style: Style) -> Line<'static> {
let used = label.chars().count() + 1;
let rule = "─".repeat(usize::from(width).saturating_sub(used));
Line::from(Span::styled(format!("{label} {rule}"), style))
}
fn group_line(
app: &App,
name: &str,
columns: &[Column],
width: u16,
selected: bool,
) -> Line<'static> {
let palette = app.data_palette();
let members = app.group_members(name);
let totals = app.totals_for(&members);
let status = App::uniform_status_for(&members);
let status_style = status.map_or(Style::default(), |status| palette.status(status));
let status_text = App::status_text_for(&members);
let name_width = self::name_width(width, columns);
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len() * 2 + 1);
let mut used: u16 = 0;
for (index, column) in columns.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" ", ground));
used += 2;
}
let cell_width = if *column == Column::Name {
name_width
} else {
column.width()
};
let text = fit_owned(
group_cell(name, *column, &totals, &status_text, &members),
cell_width,
);
let style = cell_style(palette, *column, status_style, status, None);
let tail_style = mem_ceil_tail_style(palette, status, None, style);
push_row_cell(
&mut spans,
*column,
text,
style.patch(ground),
tail_style.patch(ground),
0,
);
used += cell_width;
}
pad_ground(&mut spans, used, width, ground);
Line::from(spans)
}
fn group_cell(
name: &str,
column: Column,
totals: &GroupTotals,
status_text: &str,
members: &[&Row],
) -> String {
match column {
Column::Id
| Column::Pid
| Column::Exit
| Column::Cfg
| Column::CpuSpark
| Column::MemCeil => String::new(),
Column::Name => format!("{name} \u{d7}{}", totals.count),
Column::Status => status_text.to_string(),
Column::Restarts => totals.restarts.to_string(),
Column::Cpu => totals
.cpu
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
Column::Mem => totals.memory.map_or_else(|| "-".to_string(), human_bytes),
Column::Uptime => totals
.uptime_ms
.map_or_else(|| "-".to_string(), human_duration),
Column::Fold => members
.first()
.and_then(|row| row.info.fold.clone())
.unwrap_or_else(|| "-".to_string()),
Column::Smit => members
.first()
.and_then(|row| row.info.smit.clone())
.unwrap_or_else(|| "-".to_string()),
}
}
#[must_use]
pub fn row_line(
app: &App,
facts: &FrameFacts<'_>,
row: &Row,
columns: &[Column],
width: u16,
selected: bool,
) -> Line<'static> {
let palette = app.data_palette();
let grouped = facts.is_grouped(&row.info.name);
let name = name_width(width, columns);
let status_style = palette.reported(row.reported());
let status = Some(row.info.status);
let mem_ceil_ratio = mem_ceil_ratio(&row.info);
let mem_ceil_fill = self::mem_ceil_fill(&row.info);
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len() * 2 + 1);
let mut used: u16 = 0;
for (index, column) in columns.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" ", ground));
used += 2;
}
let cell_width = if *column == Column::Name {
name
} else {
column.width()
};
let text = fit_owned(
cell(app, row, *column, grouped, facts.cpu_ceiling),
cell_width,
);
let style = cell_style(palette, *column, status_style, status, mem_ceil_ratio);
let tail_style = mem_ceil_tail_style(palette, status, mem_ceil_ratio, style);
push_row_cell(
&mut spans,
*column,
text,
style.patch(ground),
tail_style.patch(ground),
mem_ceil_fill,
);
used += cell_width;
}
pad_ground(&mut spans, used, width, ground);
Line::from(spans)
}
fn cell(app: &App, row: &Row, column: Column, grouped: bool, cpu_ceiling: f32) -> String {
let info = &row.info;
match column {
Column::Id => info.id.to_string(),
Column::Name if grouped => info
.instance
.map_or_else(String::new, |slot| format!(" \u{21b3} :{slot}")),
Column::Name => info.name.clone(),
Column::Status => row.reported().word(),
Column::Pid => info
.pid
.map_or_else(|| "-".to_string(), |pid| pid.to_string()),
Column::Restarts => info.restarts.to_string(),
Column::Exit => exit_cell(info.pid, info.last_exit),
Column::Cfg => cfg_cell(info.pending.as_deref(), info.overridden.as_deref()),
Column::CpuSpark => cpu_spark_cell(app, info, cpu_ceiling),
Column::Cpu => app
.cpu_now(info.id)
.map_or_else(|| "-".to_string(), |cpu| format!("{cpu:.1}%")),
Column::MemCeil => mem_ceil_cell(info),
Column::Mem => info
.memory_bytes
.map_or_else(|| "-".to_string(), human_bytes),
Column::Uptime => app
.uptime_ms(info.id)
.map_or_else(|| "-".to_string(), human_duration),
Column::Fold | Column::Smit if grouped => String::new(),
Column::Fold => info.fold.clone().unwrap_or_else(|| "-".to_string()),
Column::Smit => info.smit.clone().unwrap_or_else(|| "-".to_string()),
}
}
fn cpu_spark_cell(app: &App, info: &ProcessInfo, cpu_ceiling: f32) -> String {
cell::sparkline(app.cpu_history(info.id), 10, cpu_ceiling)
}
fn mem_ceil_cell(info: &ProcessInfo) -> String {
cell::gauge(info.memory_bytes.unwrap_or(0), info.max_memory, 10)
}
fn mem_ceil_fill(info: &ProcessInfo) -> usize {
cell::gauge_fill(info.memory_bytes.unwrap_or(0), info.max_memory, 10)
}
fn mem_ceil_ratio(info: &ProcessInfo) -> Option<f64> {
let value = info.memory_bytes?;
let ceiling = info.max_memory?;
if ceiling == 0 {
return None;
}
Some(value as f64 / ceiling as f64)
}
fn mem_ceil_measuring(status: Option<ProcStatus>, ratio: Option<f64>) -> bool {
status == Some(ProcStatus::Online) && ratio.is_some()
}
fn cell_style(
palette: Palette,
column: Column,
status_style: Style,
status: Option<ProcStatus>,
mem_ceil_ratio: Option<f64>,
) -> Style {
match column {
Column::Status => status_style,
Column::CpuSpark => palette.status(ProcStatus::Online),
Column::MemCeil if !mem_ceil_measuring(status, mem_ceil_ratio) => palette.muted(),
Column::MemCeil => {
if mem_ceil_ratio.is_some_and(|ratio| ratio >= 0.9) {
palette.attention()
} else {
palette.sky()
}
}
_ => Style::default(),
}
}
fn mem_ceil_tail_style(
palette: Palette,
status: Option<ProcStatus>,
mem_ceil_ratio: Option<f64>,
fill_style: Style,
) -> Style {
if mem_ceil_measuring(status, mem_ceil_ratio) {
palette.gauge_rest()
} else {
fill_style
}
}
fn push_row_cell(
spans: &mut Vec<Span<'static>>,
column: Column,
text: String,
style: Style,
tail_style: Style,
fill: usize,
) {
if column == Column::MemCeil {
let fill = fill.min(text.chars().count());
let mut chars = text.chars();
let filled: String = chars.by_ref().take(fill).collect();
let rest: String = chars.collect();
spans.push(Span::styled(filled, style));
spans.push(Span::styled(rest, tail_style));
} else {
spans.push(Span::styled(text, style));
}
}
#[cfg(test)]
mod tests {
use super::super::super::fixtures;
use super::super::columns::ALL;
use super::super::layout::fit;
use super::*;
#[test]
fn a_running_sheep_with_no_ceiling_draws_an_empty_gauge() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let info = ProcessInfo::builder(1, "web", ProcStatus::Online)
.memory_bytes(Some(48 * 1024 * 1024))
.build();
assert_eq!(mem_ceil_cell(&info), "░░░░░░░░░░");
}
#[test]
fn a_sheep_at_its_ceiling_fills_the_gauge() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let info = ProcessInfo::builder(1, "hungry", ProcStatus::Online)
.memory_bytes(Some(52 * 1024 * 1024))
.max_memory(Some(52 * 1024 * 1024))
.build();
assert_eq!(mem_ceil_cell(&info), "██████████");
}
#[test]
fn the_gauges_unfilled_tail_carries_gauge_rest_not_the_fill_role() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let palette = fixtures::coloured();
assert_ne!(
palette.sky().fg,
palette.gauge_rest().fg,
"the two roles must actually differ for this test to mean anything"
);
let info = ProcessInfo::builder(1, "hungry", ProcStatus::Online)
.memory_bytes(Some(26 * 1024 * 1024))
.max_memory(Some(52 * 1024 * 1024))
.build();
let app = fixtures::app_with(vec![info], palette);
let row = app.row(1).unwrap();
let line = row_line(&app, &FrameFacts::new(&app), row, ALL, 200, false);
let fill: Vec<&str> = line
.spans
.iter()
.filter(|span| span.style.fg == palette.sky().fg && !span.content.is_empty())
.map(|span| span.content.as_ref())
.collect();
let tail: Vec<&str> = line
.spans
.iter()
.filter(|span| span.style.fg == palette.gauge_rest().fg && !span.content.is_empty())
.map(|span| span.content.as_ref())
.collect();
assert_eq!(fill, vec!["█████"], "got fill spans {fill:?}");
assert_eq!(tail, vec!["░░░░░"], "got tail spans {tail:?}");
}
#[test]
fn a_running_sheep_with_no_ceiling_draws_muted_not_sky() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let palette = fixtures::coloured();
let info = ProcessInfo::builder(1, "web", ProcStatus::Online)
.memory_bytes(Some(48 * 1024 * 1024))
.build();
let app = fixtures::app_with(vec![info], palette);
let row = app.row(1).unwrap();
let line = row_line(&app, &FrameFacts::new(&app), row, ALL, 200, false);
let mem_ceil_text: Vec<&str> = line
.spans
.iter()
.filter(|span| span.content.as_ref() == "░░░░░░░░░░")
.map(|span| span.content.as_ref())
.collect();
assert_eq!(mem_ceil_text, vec!["░░░░░░░░░░"]);
assert!(
line.spans
.iter()
.any(|span| span.content.as_ref() == "░░░░░░░░░░"
&& span.style.fg == palette.muted().fg),
"expected the all-tail bar in muted, got {:?}",
line.spans
.iter()
.map(|s| (s.content.as_ref(), s.style.fg))
.collect::<Vec<_>>()
);
assert!(
!line
.spans
.iter()
.any(|span| span.content.as_ref() == "░░░░░░░░░░"
&& span.style.fg == palette.sky().fg),
"must not read sky, which claims a real measurement"
);
}
#[test]
fn a_stopped_sheeps_gauge_draws_muted() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let palette = fixtures::coloured();
let info = ProcessInfo::builder(1, "stopped", ProcStatus::Stopped)
.memory_bytes(Some(26 * 1024 * 1024))
.max_memory(Some(52 * 1024 * 1024))
.build();
let app = fixtures::app_with(vec![info], palette);
let row = app.row(1).unwrap();
let line = row_line(&app, &FrameFacts::new(&app), row, ALL, 200, false);
assert!(
!line
.spans
.iter()
.any(|span| span.style.fg == palette.sky().fg),
"a stopped sheep has nothing live to gauge, so no span reads sky"
);
}
#[cfg(unix)]
#[test]
fn the_exit_cell_reuses_the_same_rendering_flock_rows_uses() {
use shep_core::protocol::{ExitInfo, ProcessInfo};
use shep_core::status::ProcStatus;
let crashed = ProcessInfo::builder(1, "crashed", ProcStatus::Errored)
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let killed = ProcessInfo::builder(2, "killed", ProcStatus::Stopped)
.last_exit(Some(ExitInfo {
code: None,
signal: Some(9),
}))
.build();
let running = ProcessInfo::builder(3, "running", ProcStatus::Online)
.pid(Some(4_242))
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let app = fixtures::app_with(vec![crashed, killed, running], fixtures::plain());
let rows = app.rows();
let cell_for = |id: u32| {
let row = rows.iter().find(|row| row.info.id == id).unwrap();
cell(&app, row, Column::Exit, false, app.cpu_ceiling())
};
assert_eq!(cell_for(1), "1");
assert_eq!(cell_for(2), "SIGKILL");
assert_eq!(
cell_for(3),
"-",
"a running sheep has nothing for EXIT to say"
);
}
#[test]
fn a_group_rows_cells_show_the_apps_rollup() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.instance(Some(0))
.memory_bytes(Some(100 << 20))
.uptime_ms(120_000)
.build(),
ProcessInfo::builder(2, "web", ProcStatus::Online)
.instance(Some(1))
.memory_bytes(Some(150 << 20))
.uptime_ms(30_000)
.build(),
ProcessInfo::builder(3, "web", ProcStatus::Online)
.instance(Some(2))
.memory_bytes(Some(50 << 20))
.uptime_ms(600_000)
.build(),
],
fixtures::plain(),
);
let line = key_line(
&app,
&FrameFacts::new(&app),
&RowKey::Group("web".to_string()),
ALL,
200,
false,
);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let name = name_width(200, ALL);
let expected = [
fit("", Column::Id.width()), fit("web \u{d7}3", name), fit("online", Column::Status.width()), fit("", Column::Pid.width()), fit("0", Column::Restarts.width()), fit("", Column::Exit.width()), fit("", Column::Cfg.width()), fit("", Column::CpuSpark.width()), fit("-", Column::Cpu.width()), fit("", Column::MemCeil.width()), fit("300.0M", Column::Mem.width()),
fit("30s", Column::Uptime.width()),
fit("-", Column::Fold.width()),
fit("-", Column::Smit.width()),
]
.join(" ");
assert_eq!(rendered.trim_end(), expected.trim_end(), "got {rendered:?}");
assert_eq!(
crate::output::width::visible_width(&rendered),
200,
"the row is padded to the table's width, so its ground reaches the edge"
);
}
#[test]
fn a_slot_row_under_a_group_header_renders_as_a_slot() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let member = |id: u32, slot: u32| {
ProcessInfo::builder(id, "web", ProcStatus::Online)
.instance(Some(slot))
.pid(Some(4_000 + id))
.fold(Some("edge".to_string()))
.smit(Some("web".to_string()))
.uptime_ms(30_000)
.build()
};
let app = fixtures::app_with(vec![member(1, 0), member(2, 1)], fixtures::plain());
let line = key_line(
&app,
&FrameFacts::new(&app),
&RowKey::Sheep(2),
ALL,
200,
false,
);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let name = name_width(200, ALL);
let expected = [
fit("2", Column::Id.width()),
fit(" \u{21b3} :1", name),
fit("online", Column::Status.width()),
fit("4002", Column::Pid.width()),
fit("0", Column::Restarts.width()),
fit("-", Column::Exit.width()),
fit("-", Column::Cfg.width()),
fit(" \u{2581}", Column::CpuSpark.width()),
fit("-", Column::Cpu.width()),
fit(
"\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}",
Column::MemCeil.width(),
),
fit("-", Column::Mem.width()),
fit("30s", Column::Uptime.width()),
fit("", Column::Fold.width()),
fit("", Column::Smit.width()),
]
.join(" ");
assert_eq!(rendered.trim_end(), expected.trim_end(), "got {rendered:?}");
}
#[test]
fn an_ungrouped_sheep_still_shows_its_own_name_and_fold() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let app = fixtures::app_with(
vec![
ProcessInfo::builder(7, "solo", ProcStatus::Online)
.instance(Some(0))
.fold(Some("edge".to_string()))
.build(),
],
fixtures::plain(),
);
let line = key_line(
&app,
&FrameFacts::new(&app),
&RowKey::Sheep(7),
ALL,
200,
false,
);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains("solo"), "got {rendered:?}");
assert!(rendered.contains("edge"), "got {rendered:?}");
assert!(!rendered.contains('\u{21b3}'), "got {rendered:?}");
}
#[test]
fn a_silent_dog_reads_silent_not_online() {
use shep_core::protocol::{DogSource, ProcessInfo};
use shep_core::status::ProcStatus;
let dog = ProcessInfo::builder(9, "log-rotate", ProcStatus::Online)
.pid(Some(4_242))
.dog(Some(DogSource::Adopted {
path: "/usr/local/bin/shep-log-rotate".to_string(),
}))
.handshook(Some(false))
.build();
let app = fixtures::app_with(vec![dog], fixtures::plain());
let row = app.row(9).unwrap();
let line = row_line(&app, &FrameFacts::new(&app), row, ALL, 200, false);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rendered.contains("silent"),
"expected silent, got {rendered:?}"
);
assert!(
!rendered.contains("online"),
"must not say online: {rendered:?}"
);
}
#[test]
fn a_dog_that_has_handshook_still_reads_online() {
use shep_core::protocol::{DogSource, ProcessInfo};
use shep_core::status::ProcStatus;
let dog = ProcessInfo::builder(9, "log-rotate", ProcStatus::Online)
.pid(Some(4_242))
.dog(Some(DogSource::Adopted {
path: "/usr/local/bin/shep-log-rotate".to_string(),
}))
.handshook(Some(true))
.build();
let app = fixtures::app_with(vec![dog], fixtures::plain());
let row = app.row(9).unwrap();
let line = row_line(&app, &FrameFacts::new(&app), row, ALL, 200, false);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains("online"), "got {rendered:?}");
assert!(!rendered.contains("silent"), "got {rendered:?}");
}
#[test]
fn a_sheep_still_reads_online_and_has_no_handshake() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let sheep = ProcessInfo::builder(1, "web", ProcStatus::Online)
.pid(Some(4_000))
.build();
assert_eq!(sheep.handshook, None, "a sheep is never sent one");
let app = fixtures::app_with(vec![sheep], fixtures::plain());
let row = app.row(1).unwrap();
let line = row_line(&app, &FrameFacts::new(&app), row, ALL, 200, false);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains("online"), "got {rendered:?}");
assert!(!rendered.contains("silent"), "got {rendered:?}");
}
#[test]
fn a_sheep_never_reads_as_silent() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut impossible = ProcessInfo::builder(1, "web", ProcStatus::Online)
.pid(Some(4_000))
.build();
impossible.handshook = Some(false);
let app = fixtures::app_with(vec![impossible], fixtures::plain());
let row = app.row(1).unwrap();
let line = row_line(&app, &FrameFacts::new(&app), row, ALL, 200, false);
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rendered.contains("online"),
"the sheep table has no dogs in it, and no silence rule either: {rendered:?}"
);
assert!(!rendered.contains("silent"), "got {rendered:?}");
}
}