use std::ffi::OsStr;
use std::path::PathBuf;
use std::time::Instant;
use ratatui::text::Line;
use shep_client::RequestError;
use shep_core::config::AppConfig;
use shep_core::protocol::{BusEvent, DogSource, Lamb, ProcessInfo, Response, SheepConfigView};
use shep_core::status::ProcStatus;
use super::super::app::{
ActionVerb, App, Control, KeyPress, LambWalk, Msg, RowKey, Sent, SettingsRow,
};
use super::super::level::Level;
use super::super::source::HostSample;
use super::super::tail::{Stream, Tail, TailLine};
use super::super::theme::Palette;
use crate::commands::settings::{DogView, ScalarView, SettingField, SettingsSnapshot};
use crate::style::StyleSource;
pub fn plain() -> Palette {
Palette::detect(None, None, None)
}
pub fn coloured() -> Palette {
Palette::detect(None, Some(OsStr::new("xterm-256color")), None)
}
pub fn app_with(flock: Vec<ProcessInfo>, palette: Palette) -> App {
let t0 = Instant::now();
let mut app = App::new(
palette,
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: flock,
at: t0,
});
app
}
pub fn sheep_in_fold(id: u32, name: &str, fold: Option<&str>) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(1000 + id))
.uptime_ms(60_000)
.fold(fold.map(str::to_string))
.build()
}
#[must_use]
pub fn sheep_in_fold_with_status(
id: u32,
name: &str,
fold: Option<&str>,
status: ProcStatus,
) -> ProcessInfo {
ProcessInfo::builder(id, name, status)
.pid(Some(1000 + id))
.uptime_ms(60_000)
.fold(fold.map(str::to_string))
.build()
}
pub fn sheep_with(
id: u32,
name: &str,
fold: Option<&str>,
uptime_ms: u64,
memory: Option<u64>,
restarts: u32,
) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(1000 + id))
.uptime_ms(uptime_ms)
.memory_bytes(memory)
.restarts(restarts)
.fold(fold.map(str::to_string))
.build()
}
pub fn instance_in_fold(id: u32, name: &str, slot: u32, fold: Option<&str>) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(1000 + id))
.uptime_ms(60_000)
.instance(Some(slot))
.fold(fold.map(str::to_string))
.build()
}
pub fn flock_of(count: u32, with_readings: u32) -> Vec<ProcessInfo> {
(0..count)
.map(|id| {
let reports = id < with_readings;
ProcessInfo::builder(id, format!("sheep-{id}"), ProcStatus::Online)
.pid(Some(48_000 + id))
.uptime_ms(4_512_000)
.cpu_percent(reports.then_some(3.5))
.memory_bytes(reports.then_some(182 << 20))
.out_file(Some(format!("/home/ada/.shep/logs/sheep-{id}-out.log")))
.err_file(Some(format!("/home/ada/.shep/logs/sheep-{id}-err.log")))
.build()
})
.collect()
}
pub fn app_with_a_dog() -> App {
let flock = vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.pid(Some(48_001))
.uptime_ms(4_512_000)
.build(),
ProcessInfo::builder(2, "api", ProcStatus::Online)
.pid(Some(48_002))
.uptime_ms(4_512_000)
.build(),
ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(DogSource::BuiltIn))
.build(),
];
app_with(flock, plain())
}
pub fn app_with_a_dog_selected_and_control() -> App {
let decoy = ProcessInfo::builder(0, "!decoy", ProcStatus::Online).build();
let dog = ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(DogSource::Adopted {
path: "/opt/otel".to_string(),
}))
.build();
let mut app = app_with(vec![decoy, dog], plain());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::SelectDown));
app
}
pub fn app_with_a_built_in_dog_selected_and_control() -> App {
let decoy = ProcessInfo::builder(0, "!decoy", ProcStatus::Online).build();
let dog = ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(DogSource::BuiltIn))
.build();
let mut app = app_with(vec![decoy, dog], plain());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::SelectDown));
app
}
pub fn sample() -> HostSample {
HostSample {
load: (2.31, 4.10, 3.88),
cores: Some(10),
memory_total_bytes: 32 << 30,
memory_used_bytes: 12 * (1 << 30) + (410 << 20),
uptime_seconds: 6 * 86_400 + 3 * 3_600,
}
}
pub fn with_host(sample: HostSample, flock: Vec<ProcessInfo>) -> App {
let mut app = app_with(flock, plain());
app.update(Msg::Host {
sample: Some(sample),
});
app
}
pub fn with_host_none(flock: Vec<ProcessInfo>, unsupported: bool) -> App {
let mut app = app_with(flock, plain());
if unsupported {
app.update(Msg::Host { sample: None });
}
app
}
pub fn with_feed(tail: Tail) -> App {
let mut app = app_with(flock_of(3, 0), plain());
app.update(Msg::Bleats { tail });
app
}
pub fn with_feed_and_selection(tail: Tail, id: u32) -> App {
let mut app = app_with(flock_of(3, 0), plain());
for _ in 0..id {
app.update(Msg::Key(KeyPress::SelectDown));
}
app.update(Msg::Bleats { tail });
app
}
pub fn with_feed_and_palette(tail: Tail, palette: Palette) -> App {
let mut app = app_with(flock_of(3, 0), palette);
app.update(Msg::Bleats { tail });
app
}
pub fn with_no_selection() -> App {
app_with(Vec::new(), plain())
}
pub fn with_selection(info: ProcessInfo) -> App {
with_selection_and_palette(info, plain())
}
pub fn with_selection_and_palette(info: ProcessInfo, palette: Palette) -> App {
assert!(
info.id > 0,
"the decoy takes id 0, so the sheep under test cannot"
);
let wanted = info.id;
let decoy = ProcessInfo::builder(0, "!decoy", ProcStatus::Online).build();
let mut app = app_with(vec![decoy, info], palette);
app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(
app.selected(),
Some(RowKey::Sheep(wanted)),
"the sheep under test must end up selected, and on row 1: the mutation \
this fixture exists to catch reads row 0 instead"
);
app
}
pub fn sheep_with_lambs() -> ProcessInfo {
ProcessInfo::builder(9, "gateway", ProcStatus::Online)
.pid(Some(48_301))
.lambs(Some(vec![
Lamb::new(48_302, "node"),
Lamb::new(48_303, "sh"),
]))
.build()
}
pub fn with_lamb_reading(walk: LambWalk) -> App {
with_lamb_reading_for(9, walk)
}
pub fn with_lamb_reading_for(id: u32, walk: LambWalk) -> App {
let mut app = with_selection(sheep_with_lambs());
app.update(Msg::Replied {
sent: Sent::Lambs { id },
result: reply_for(id, &walk),
});
app
}
pub fn app_with_lamb_reading_at(walk: LambWalk) -> (App, Instant) {
let t0 = Instant::now();
let mut app = with_selection(sheep_with_lambs());
app.update(Msg::Tick { now: t0 });
app.update(Msg::Replied {
sent: Sent::Lambs { id: 9 },
result: reply_for(9, &walk),
});
(app, t0)
}
fn reply_for(id: u32, walk: &LambWalk) -> Result<Response, RequestError> {
let lambs = match walk {
LambWalk::Failed => return Err(RequestError::Closed),
LambWalk::NotWalked => None,
LambWalk::Walked(lambs) => Some(lambs.clone()),
};
Ok(Response::Described(vec![
ProcessInfo::builder(id, "gateway", ProcStatus::Online)
.pid(Some(48_301))
.lambs(lambs)
.build(),
]))
}
pub fn lamb_line_of(app: &App) -> String {
render_all(&super::detail::detail_lines(app, 200))
.lines()
.find(|line| line.starts_with("lambs "))
.map(str::to_string)
.expect("the pane has a lamb line")
}
pub fn full_app() -> App {
let mut app = app_with(flock_of(12, 12), plain());
app.update(Msg::Bleats {
tail: Tail {
lines: (0..10)
.map(|n| line(Stream::Out, &format!("line-{n}")))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app
}
pub fn bleats_pane_with_filters() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: vec![
line(Stream::Err, "ERROR pool exhausted"), line(Stream::Out, "ERROR pool exhausted"), line(Stream::Err, "INFO pool warming"), line(Stream::Err, "ERROR disk full"), ],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 128,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
let pane = app
.bleats_pane_mut_for_tests()
.expect("Msg::Key(KeyPress::Bleats) opened the pane on the sheep selected above");
pane.set_stream(Some(Stream::Err));
pane.set_min_level(Some(Level::Warn));
pane.set_match("pool".to_string());
app
}
pub fn bleats_pane_with_lines(n: u32) -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: (0..n)
.map(|i| line(Stream::Out, &format!("line-{i}")))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app
}
#[must_use]
pub fn bleats_pane_with_mixed_line_lengths() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let mut lines: Vec<TailLine> = (0..40)
.map(|i| {
let padding = "y".repeat(50 * (i % 4));
line(Stream::Out, &format!("old-{i} {padding}"))
})
.collect();
lines.extend((0..40).map(|i| line(Stream::Out, &format!("new-{i}"))));
app.update(Msg::Bleats {
tail: Tail {
lines,
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app
}
#[must_use]
pub fn bleats_pane_with_a_wide_line() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: vec![line(Stream::Out, &"\u{5e83}".repeat(60))],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app
}
pub fn bleats_pane_with_long_line() -> App {
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.update(Msg::Bleats {
tail: Tail {
lines: vec![
line(Stream::Out, "short line"),
line(Stream::Out, &"x".repeat(200)),
],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app.update(Msg::Key(KeyPress::Bleats));
app
}
pub fn draw_lines(app: &App, width: u16, rows: usize) -> Vec<Line<'static>> {
super::bleats_full::draw_lines(app, width, rows)
}
pub fn app_fixture() -> App {
let dir = tempfile::Builder::new()
.prefix("shep-fx-")
.tempdir()
.expect("a tempdir for the fixture's logs");
let out_path = dir.path().join("catcher-out.log");
let err_path = dir.path().join("catcher-err.log");
std::fs::write(&out_path, b"listening on :8080\n").expect("write the out log");
std::fs::write(&err_path, b"warn: retrying upstream\n").expect("write the err log");
let _ = dir.keep();
let info = ProcessInfo::builder(7, "catcher", ProcStatus::Online)
.pid(Some(48_107))
.uptime_ms(4_512_000)
.out_file(Some(out_path.display().to_string()))
.err_file(Some(err_path.display().to_string()))
.build();
let mut app = with_selection(info);
app.update(Msg::Bleats {
tail: Tail {
lines: vec![
line(Stream::Out, "listening on :8080"),
line(Stream::Err, "warn: retrying upstream"),
],
missed_lines: 0,
missed_bytes: 0,
read_bytes: 1_024,
note: None,
},
});
app
}
pub fn line(stream: Stream, text: &str) -> TailLine {
TailLine {
stream,
text: text.to_string(),
}
}
pub fn rendered(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
pub fn render_all(lines: &[Line<'static>]) -> String {
lines.iter().map(rendered).collect::<Vec<_>>().join("\n")
}
pub fn filtered_app(query: &str) -> App {
filtered_app_of(named_flock(), query)
}
pub fn filtered_app_of(flock: Vec<ProcessInfo>, query: &str) -> App {
let mut app = app_with(flock, plain());
if !query.is_empty() {
app.update(Msg::Key(KeyPress::FilterStart));
for typed in query.chars() {
app.update(Msg::Key(KeyPress::TextChar(typed)));
}
app.update(Msg::Key(KeyPress::TextApply));
}
app
}
pub fn editing_app(query: &str) -> App {
let mut app = app_with(named_flock(), plain());
app.update(Msg::Key(KeyPress::FilterStart));
for typed in query.chars() {
app.update(Msg::Key(KeyPress::TextChar(typed)));
}
app
}
fn named_flock() -> Vec<ProcessInfo> {
[(1, "web"), (2, "api"), (3, "web-worker"), (4, "cron")]
.into_iter()
.map(|(id, name)| {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(48_000 + id))
.uptime_ms(4_512_000)
.build()
})
.collect()
}
pub fn allowed_app() -> App {
let mut app = app_with(named_flock(), plain());
app.set_control_for_tests(Control::Allowed);
for _ in 0..named_flock().len() {
if app.selected() == Some(RowKey::Sheep(2)) {
break;
}
app.update(Msg::Key(KeyPress::SelectDown));
}
assert_eq!(
app.selected(),
Some(RowKey::Sheep(2)),
"the cursor must end up on api"
);
app
}
pub fn armed_app(verb: ActionVerb) -> App {
let mut app = allowed_app();
app.update(Msg::Key(KeyPress::Action(verb)));
app
}
pub fn acting_app(verb: ActionVerb) -> App {
let mut app = armed_app(verb);
app.update(Msg::Key(KeyPress::Confirm));
app
}
pub fn armed_app_with_a_filter_and_a_notice() -> App {
let mut app = filtered_app("api");
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
app
}
pub fn settings_snapshot() -> SettingsSnapshot {
let config = |value: &str| ScalarView {
value: value.to_string(),
source: StyleSource::Config,
};
SettingsSnapshot {
log_level: config("warn"),
log_json: config("false"),
socket: config("/home/ada/.shep/run/shep.sock"),
max_cron_sleep: config("30s"),
allow_control: config("false"),
style_level: config("full"),
style_level_in_file: Some("full".to_string()),
dogs: vec![
DogView {
name: "bark".to_string(),
enabled: false,
adopted_path: None,
},
DogView {
name: "metrics".to_string(),
enabled: true,
adopted_path: None,
},
],
}
}
pub fn app_in_settings() -> App {
let mut app = app_with(flock_of(3, 0), plain());
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot()),
});
app
}
pub fn app_in_settings_on(field: SettingField) -> App {
let mut app = app_in_settings_with_control();
let target = app
.settings()
.unwrap()
.rows()
.iter()
.position(|row| *row == SettingsRow::Scalar(field))
.expect("field is one of the six scalar rows Settings::rows always carries");
for _ in 0..target {
app.update(Msg::Key(KeyPress::SelectDown));
}
app
}
pub fn app_in_settings_at() -> (App, Instant) {
let t0 = Instant::now();
let mut app = App::new(plain(), Control::Allowed, "/home/ada/.shep".to_string(), t0);
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot()),
});
(app, t0)
}
pub fn app_in_settings_with_shadowed_style(source: StyleSource) -> App {
let mut app = app_in_settings_on(SettingField::StyleLevel);
let mut snapshot = settings_snapshot();
snapshot.style_level = ScalarView {
value: "bare".to_string(),
source,
};
snapshot.style_level_in_file = Some("full".to_string());
app.update(Msg::Settings {
result: Ok(snapshot),
});
app
}
pub fn app_in_settings_with_control() -> App {
let mut app = app_in_settings();
app.set_control_for_tests(Control::Allowed);
app
}
fn settings_snapshot_with_dogs(dogs: Vec<DogView>) -> SettingsSnapshot {
SettingsSnapshot {
dogs,
..settings_snapshot()
}
}
pub fn app_in_settings_with_dog_drift() -> App {
let flock = vec![
ProcessInfo::builder(90, "otel", ProcStatus::Online)
.pid(Some(90_000))
.dog(Some(DogSource::BuiltIn))
.build(),
];
let mut app = app_with(flock, plain());
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot_with_dogs(vec![
DogView {
name: "otel".to_string(),
enabled: false,
adopted_path: Some(PathBuf::from("/usr/local/bin/shep-otel")),
},
DogView {
name: "ledger".to_string(),
enabled: true,
adopted_path: Some(PathBuf::from("/opt/ledger/bin/dog")),
},
])),
});
app
}
pub fn app_in_settings_with_silent_dog() -> App {
let flock = vec![
ProcessInfo::builder(91, "bark", ProcStatus::Online)
.pid(Some(91_000))
.dog(Some(DogSource::BuiltIn))
.handshook(Some(false))
.build(),
];
let mut app = app_with(flock, plain());
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot_with_dogs(vec![DogView {
name: "bark".to_string(),
enabled: true,
adopted_path: None,
}])),
});
app
}
fn settings_snapshot_for_toggle_tests() -> SettingsSnapshot {
settings_snapshot_with_dogs(vec![
DogView {
name: "metrics".to_string(),
enabled: false,
adopted_path: None,
},
DogView {
name: "otel".to_string(),
enabled: true,
adopted_path: Some(PathBuf::from("/usr/local/bin/shep-otel")),
},
])
}
pub fn app_in_settings_on_dog(name: &str) -> App {
let mut app = app_with(flock_of(3, 0), plain());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot_for_toggle_tests()),
});
let dog_index = settings_snapshot_for_toggle_tests()
.dogs
.iter()
.position(|dog| dog.name == name)
.expect("name is one of settings_snapshot_for_toggle_tests's own dogs");
for _ in 0..(6 + dog_index) {
app.update(Msg::Key(KeyPress::SelectDown));
}
app
}
pub fn app_in_settings_on_enabled_dog(name: &str) -> App {
app_in_settings_on_dog(name)
}
pub fn sheep_config_view() -> SheepConfigView {
sheep_config_view_parking(vec!["kill_signal".to_string()])
}
fn sheep_config_view_parking(pending: Vec<String>) -> SheepConfigView {
let mut config = AppConfig {
name: "web".to_string(),
script: "./srv".to_string(),
args: vec!["--port".to_string(), "8080".to_string()],
max_restarts: 32,
instances: 3,
..AppConfig::default()
};
config
.env
.insert("DB_HOST".to_string(), "db.internal".to_string());
config
.env
.insert("LOG_LEVEL".to_string(), "debug".to_string());
SheepConfigView::new(
config,
vec!["max_restarts".to_string(), "reuse_port".to_string()],
pending,
)
}
pub fn dog_section() -> String {
"# how often\npoll = \"60s\"\nhistory_bytes = 4096\n\n[sinks.ops]\nkind = \"slack\"\nurl = \"https://hooks.example/x\"\n"
.to_string()
}
pub fn app_in_dog_pane() -> App {
let mut app = app_with(flock_of(3, 0), plain());
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Settings));
app.update(Msg::Settings {
result: Ok(settings_snapshot()),
});
for _ in 0..6 {
app.update(Msg::Key(KeyPress::SelectDown));
}
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::DogPane {
name: "bark".to_string(),
adopted_path: None,
result: Ok(crate::dog::builtin_schema("bark").expect("bark is a built-in")),
});
app.update(Msg::Replied {
sent: Sent::DogSection {
name: "bark".to_string(),
},
result: Ok(Response::DogSection {
toml: dog_section().into(),
}),
});
app
}
pub fn app_in_sheep_pane_with_control() -> App {
let mut app = with_selection(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.pid(Some(48_000))
.build(),
);
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view()))),
});
app
}
pub fn app_in_sheep_pane_with_a_parked_field() -> App {
app_in_sheep_pane_with_control()
}
pub fn app_in_sheep_pane_with_two_parked_fields() -> App {
app_in_sheep_pane_parking(vec!["kill_signal".to_string(), "script".to_string()])
}
pub fn app_in_sheep_pane_with_nothing_parked() -> App {
app_in_sheep_pane_parking(Vec::new())
}
fn app_in_sheep_pane_parking(pending: Vec<String>) -> App {
let mut app = with_selection(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.pid(Some(48_000))
.build(),
);
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view_parking(
pending,
)))),
});
app
}
pub fn app_in_sheep_pane() -> App {
let mut app = with_selection(
ProcessInfo::builder(9, "web", ProcStatus::Online)
.pid(Some(48_000))
.build(),
);
app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(sheep_config_view()))),
});
app
}