use serde::Serialize;
use shep_core::protocol::{Lamb, ProcessInfo};
use crate::output::Render;
use crate::style::Presentation;
use super::toolkit::{
GroupTotals, group_paint, group_row, group_totals, name_groups, paint, plain_row,
process_info_paint, slot_row,
};
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct FlockRows(pub Vec<ProcessInfo>);
impl Render for FlockRows {
fn headers() -> &'static [&'static str] {
&[
"ID", "NAME", "STATUS", "PID", "RESTARTS", "EXIT", "CFG", "CPU", "MEM", "UPTIME",
"FOLD", "SMIT",
]
}
fn rows(&self) -> Vec<Vec<String>> {
name_groups(&self.0)
.flat_map(|group| {
let slotted = group.len() > 1 && group.iter().all(|p| p.instance.is_some());
group.iter().map(move |p| plain_row(p, slotted))
})
.collect()
}
fn rows_for(&self, presentation: Presentation, status_word: bool) -> Vec<Vec<String>> {
enum RowSource<'a> {
Sheep(&'a ProcessInfo),
Group(&'a [ProcessInfo], GroupTotals),
}
let mut out = Vec::with_capacity(self.0.len());
let mut sources: Vec<RowSource<'_>> = Vec::with_capacity(self.0.len());
for group in name_groups(&self.0) {
let slotted = group.len() > 1 && group.iter().all(|p| p.instance.is_some());
if slotted && presentation.level.boxes() {
let totals = group_totals(group);
out.push(group_row(group, &totals));
sources.push(RowSource::Group(group, totals));
for p in group {
out.push(slot_row(p));
sources.push(RowSource::Sheep(p));
}
} else {
for p in group {
out.push(plain_row(p, slotted));
sources.push(RowSource::Sheep(p));
}
}
}
paint(
out,
Self::headers(),
presentation,
status_word,
|header, _cell, index| match &sources[index] {
RowSource::Sheep(p) => process_info_paint(header, p),
RowSource::Group(g, totals) => group_paint(header, g, totals),
},
)
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ID" => "id",
"NAME" => "name",
"STATUS" => "status",
"PID" => "pid",
"RESTARTS" => "restarts",
"EXIT" => "last_exit",
"CFG" => "pending",
"CPU" => "cpu_percent",
"MEM" => "memory_bytes",
"UPTIME" => "uptime_ms",
"FOLD" => "fold",
"SMIT" => "smit",
other => panic!("FlockRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[
"out_file",
"err_file",
"dog",
"lambs",
"handshook",
"dog_stale",
"instance",
"overridden",
"depends_on",
"max_memory",
"cpu_ms",
"reload_deadline_ms",
];
const PRIORITIES: &'static [u8] = &[0, 0, 0, 2, 4, 6, 6, 5, 3, 1, 7, 8];
}
#[derive(Debug, Serialize)]
pub struct LambRows(pub Vec<Lamb>);
impl Render for LambRows {
fn headers() -> &'static [&'static str] {
&["PID", "NAME"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|lamb| vec![lamb.pid.to_string(), lamb.name.clone()])
.collect()
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"PID" => "pid",
"NAME" => "name",
other => panic!("LambRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[];
const PRIORITIES: &'static [u8] = &[0, 0];
}
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub struct FlushedRows(pub Vec<ProcessInfo>);
impl Render for FlushedRows {
fn headers() -> &'static [&'static str] {
&["ID", "NAME", "OUT_FILE", "ERR_FILE"]
}
fn rows(&self) -> Vec<Vec<String>> {
self.0
.iter()
.map(|p| {
vec![
p.id.to_string(),
p.name.clone(),
p.out_file.clone().unwrap_or_else(|| "-".to_string()),
p.err_file.clone().unwrap_or_else(|| "-".to_string()),
]
})
.collect()
}
fn rows_for(&self, presentation: Presentation, status_word: bool) -> Vec<Vec<String>> {
paint(
self.rows(),
Self::headers(),
presentation,
status_word,
|header, _cell, index| process_info_paint(header, &self.0[index]),
)
}
#[track_caller]
fn json_key_for(header: &str) -> &'static str {
match header {
"ID" => "id",
"NAME" => "name",
"OUT_FILE" => "out_file",
"ERR_FILE" => "err_file",
other => panic!("FlushedRows::headers() does not include {other:?}"),
}
}
const JSON_ONLY: &'static [&'static str] = &[
"status",
"pid",
"restarts",
"uptime_ms",
"fold",
"cpu_percent",
"memory_bytes",
"cpu_ms",
"dog",
"handshook",
"dog_stale",
"lambs",
"last_exit",
"smit",
"instance",
"pending",
"overridden",
"depends_on",
"max_memory",
];
const PRIORITIES: &'static [u8] = &[0, 0, 7, 6];
}
#[cfg(test)]
pub(crate) mod tests {
use shep_core::protocol::ExitInfo;
use shep_core::status::ProcStatus;
use crate::vocabulary::Role;
use super::super::dogs::tests::{cell_of, styled};
use super::super::tests::{assert_no_drift, coloured, painted, sample_flock, sample_info};
use super::*;
#[test]
fn flock_rows_do_not_drift() {
let FlockRows(mut rows) = sample_flock();
for row in &mut rows {
row.reload_deadline_ms = Some(15_000);
}
assert_no_drift(
&FlockRows(rows),
|j| &j[0],
&["UPTIME", "CPU", "MEM", "EXIT", "CFG"],
);
}
#[test]
fn the_exit_column_shows_the_last_exit_only_for_a_sheep_that_is_not_running() {
let headers = FlockRows::headers();
let at = |cells: &[String], h: &str| {
cells[headers.iter().position(|x| *x == h).unwrap()].clone()
};
let never_run = ProcessInfo::builder(1, "fresh", ProcStatus::Stopped).build();
let crashed = ProcessInfo::builder(2, "crashed", ProcStatus::Errored)
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let killed = ProcessInfo::builder(3, "killed", ProcStatus::Stopped)
.last_exit(Some(ExitInfo {
code: None,
signal: Some(9),
}))
.build();
let running_again = ProcessInfo::builder(4, "recovered", ProcStatus::Online)
.pid(Some(4242))
.last_exit(Some(ExitInfo {
code: Some(1),
signal: None,
}))
.build();
let rows = FlockRows(vec![never_run, crashed, killed, running_again]).rows();
assert_eq!(at(&rows[0], "EXIT"), "-");
assert_eq!(at(&rows[1], "EXIT"), "1");
#[cfg(unix)]
assert_eq!(at(&rows[2], "EXIT"), "SIGKILL");
#[cfg(not(unix))]
assert_eq!(at(&rows[2], "EXIT"), "9");
assert_eq!(at(&rows[3], "EXIT"), "-");
}
#[test]
fn lamb_rows_do_not_drift() {
assert_no_drift(
&LambRows(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]),
|j| &j[0],
&[],
);
}
#[test]
fn a_sheep_with_no_reading_renders_a_dash_not_a_zero() {
let mut info = sample_info(1, "web", 60_000);
info.cpu_percent = None;
info.memory_bytes = None;
let rows = FlockRows(vec![info]);
let cells = &rows.rows()[0];
let headers = FlockRows::headers();
let cpu = cells[headers.iter().position(|h| *h == "CPU").unwrap()].clone();
let mem = cells[headers.iter().position(|h| *h == "MEM").unwrap()].clone();
assert_eq!(cpu, "-");
assert_eq!(mem, "-");
}
pub(crate) fn full_presentation() -> Presentation {
use crate::style::StyleLevel;
Presentation::new(
StyleLevel::Full,
Some(std::ffi::OsStr::new("1")),
None,
None,
200,
)
}
fn bare_presentation() -> Presentation {
use crate::style::StyleLevel;
Presentation::new(StyleLevel::Bare, None, None, None, 200)
}
#[test]
fn a_single_instance_app_is_untouched_by_grouping() {
let rows = FlockRows(vec![
ProcessInfo::builder(4, "api", ProcStatus::Online)
.instance(Some(0))
.build(),
]);
let rendered = rows.rows_for(full_presentation(), true);
assert_eq!(rendered.len(), 1, "no group row for one instance");
assert_eq!(rendered[0][1], "api", "and no suffix");
}
#[test]
fn a_multi_instance_app_gets_a_group_row_then_its_slots() {
let rows = FlockRows(
(0..3)
.map(|slot| {
ProcessInfo::builder(slot + 1, "web", ProcStatus::Online)
.instance(Some(slot))
.build()
})
.collect(),
);
let rendered = rows.rows_for(full_presentation(), true);
assert_eq!(rendered.len(), 4, "one group row plus three slots");
assert_eq!(rendered[0][0], "", "the group row has no id");
assert!(rendered[0][1].contains("web"), "{:?}", rendered[0]);
assert!(
rendered[0][1].contains('3'),
"and the count: {:?}",
rendered[0]
);
assert_eq!(rendered[1][0], "1", "slot rows keep their ids");
}
#[test]
fn a_mixed_group_says_so_rather_than_picking_a_winner() {
let rows = FlockRows(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.instance(Some(0))
.build(),
ProcessInfo::builder(2, "web", ProcStatus::Stopped)
.instance(Some(1))
.build(),
ProcessInfo::builder(3, "web", ProcStatus::Online)
.instance(Some(2))
.build(),
]);
let rendered = rows.rows_for(full_presentation(), true);
assert_eq!(rendered[0][2], "2 online, 1 stopped");
}
#[test]
fn a_group_uptime_is_the_shortest_of_its_slots() {
let rows = FlockRows(
[9_000_000_u64, 4_512_000, 300_000]
.into_iter()
.enumerate()
.map(|(slot, uptime_ms)| {
let slot = u32::try_from(slot).unwrap();
ProcessInfo::builder(slot + 1, "web", ProcStatus::Online)
.instance(Some(slot))
.uptime_ms(uptime_ms)
.build()
})
.collect(),
);
let rendered = rows.rows_for(full_presentation(), true);
assert_eq!(rendered[0][9], "5m", "300_000ms, the shortest of the three");
}
#[test]
fn a_group_with_no_readings_shows_a_dash_not_a_zero() {
let rows = FlockRows(
(0..2)
.map(|slot| {
ProcessInfo::builder(slot + 1, "web", ProcStatus::Online)
.instance(Some(slot))
.cpu_percent(None)
.memory_bytes(None)
.build()
})
.collect(),
);
let rendered = rows.rows_for(full_presentation(), true);
assert_eq!(rendered[0][7], "-", "cpu");
assert_eq!(rendered[0][8], "-", "mem");
let mixed = FlockRows(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.instance(Some(0))
.cpu_percent(Some(2.5))
.memory_bytes(Some(64 << 20))
.build(),
ProcessInfo::builder(2, "web", ProcStatus::Online)
.instance(Some(1))
.cpu_percent(None)
.memory_bytes(None)
.build(),
]);
let rendered = mixed.rows_for(full_presentation(), true);
assert_eq!(rendered[0][7], "2.5%", "cpu");
assert_eq!(rendered[0][8], "64.0M", "mem");
}
#[test]
fn a_flat_style_suffixes_the_name_instead_of_grouping() {
let rows = FlockRows(
(0..2)
.map(|slot| {
ProcessInfo::builder(slot + 1, "web", ProcStatus::Online)
.instance(Some(slot))
.build()
})
.collect(),
);
let rendered = rows.rows_for(bare_presentation(), true);
assert_eq!(rendered.len(), 2, "one line per process, still greppable");
assert_eq!(rendered[0][1], "web:0");
assert_eq!(rendered[1][1], "web:1");
}
#[test]
fn the_bare_path_reaches_the_suffix_through_rows_not_rows_for() {
let rows = FlockRows(
(0..2)
.map(|slot| {
ProcessInfo::builder(slot + 1, "web", ProcStatus::Online)
.instance(Some(slot))
.build()
})
.collect(),
);
let rendered = crate::output::render_table(&rows);
assert!(rendered.contains("web:0"), "{rendered}");
assert!(rendered.contains("web:1"), "{rendered}");
}
#[test]
fn a_row_from_an_older_daemon_renders_exactly_as_it_did_before() {
let rows = FlockRows(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
ProcessInfo::builder(2, "web", ProcStatus::Online).build(),
]);
let rendered = rows.rows_for(full_presentation(), true);
assert_eq!(rendered.len(), 2, "no slots, so no grouping");
assert_eq!(rendered[0][1], "web", "and no suffix");
}
#[test]
fn flushed_rows_do_not_drift() {
assert_no_drift(&FlushedRows(sample_flock().0), |j| &j[0], &[]);
}
#[test]
fn a_flush_serializes_the_same_record_the_other_flock_verbs_do() {
let flock = serde_json::to_value(sample_flock()).unwrap();
let flushed = serde_json::to_value(FlushedRows(sample_flock().0)).unwrap();
assert_eq!(
flock, flushed,
"the table may differ between these two verbs; the JSON payload may not"
);
}
#[test]
fn the_flock_listing_drops_its_columns_in_the_documented_order() {
let mut ranked: Vec<(&str, u8)> = FlockRows::headers()
.iter()
.copied()
.zip(FlockRows::PRIORITIES.iter().copied())
.collect();
ranked.sort_by_key(|&(_, priority)| priority);
let order: Vec<&str> = ranked.iter().map(|&(header, _)| header).collect();
assert_eq!(
order,
vec![
"ID", "NAME", "STATUS", "UPTIME", "PID", "MEM", "RESTARTS", "CPU", "EXIT", "CFG", "FOLD", "SMIT",
],
"the flock listing's drop order changed; if that is deliberate, \
change this test and say why in the commit"
);
}
#[test]
fn a_flushed_row_mutes_its_id_and_its_dash_and_leaves_a_path_alone() {
let mut without = sample_info(1, "cron", 0);
without.out_file = None;
without.err_file = None;
let rows =
FlushedRows(vec![sample_info(0, "web", 60_000), without]).rows_for(coloured(), true);
assert_eq!(rows[0][0], painted("0", Role::Ink3), "ID is chrome");
assert_eq!(rows[0][1], "web", "NAME is plain");
assert_eq!(
rows[0][2], "/logs/web-0-out.log",
"a real path carries no colour"
);
assert_eq!(rows[1][2], painted("-", Role::Ink3), "the placeholder does");
assert_eq!(rows[1][3], painted("-", Role::Ink3));
}
#[test]
fn lamb_rows_carry_no_colour_at_all() {
let rows = LambRows(vec![Lamb::new(48_302, "node")]).rows_for(coloured(), true);
assert_eq!(rows[0], vec!["48302".to_string(), "node".to_string()]);
}
#[test]
fn chrome_and_placeholder_columns_are_coloured_and_nothing_else_is() {
use crate::style::{Presentation, StyleLevel};
let presentation = Presentation::new(
StyleLevel::Full,
None,
Some(std::ffi::OsStr::new("xterm-256color")),
None,
200,
);
let mut running = sample_info(0, "web", 60_000);
running.fold = None;
let mut stopped = sample_info(1, "cron", 0);
stopped.pid = None;
stopped.fold = None;
let flock = FlockRows(vec![running, stopped]);
let rows = flock.rows_for(presentation, true);
assert!(rows[0][0].contains('\u{1b}'), "{:?}", rows[0][0]);
assert!(rows[1][0].contains('\u{1b}'), "{:?}", rows[1][0]);
assert!(!rows[0][3].contains('\u{1b}'), "{:?}", rows[0][3]);
assert!(rows[1][3].contains('\u{1b}'), "{:?}", rows[1][3]);
assert!(rows[0][10].contains('\u{1b}'), "{:?}", rows[0][10]);
assert!(rows[1][10].contains('\u{1b}'), "{:?}", rows[1][10]);
}
#[test]
fn a_sheep_never_reads_as_silent() {
use crate::style::StyleLevel;
let sheep = sample_info(1, "web", 60_000);
assert_eq!(sheep.handshook, None, "the daemon sends nothing here");
let rows = FlockRows(vec![sheep.clone()]).rows();
assert_eq!(cell_of::<FlockRows>(&rows[0], "STATUS"), "online");
let mut impossible = sheep;
impossible.handshook = Some(false);
let full = FlockRows(vec![impossible]).rows_for(styled(StyleLevel::Full), true);
assert_eq!(
cell_of::<FlockRows>(&full[0], "STATUS"),
painted("(o.o) online", Role::Meadow),
"the sheep table has no dogs in it, and no silence rule either"
);
}
}