use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde_json::{Map, Value, json};
use shep_core::config::AppConfig;
use shep_core::protocol::{DogSource, ExitInfo, ProcessInfo, SheepConfigView};
use shep_core::status::ProcStatus;
use super::super::app::{App, KeyPress, Msg, RowKey, SettingsRow};
use super::super::pane::{ListedDog, SheepDogEntry};
use super::super::tail::{Stream, Tail, TailLine};
use crate::commands::settings::{DogView, SettingsSnapshot};
use crate::lookout::field::shep_toml_shape;
use crate::style::{StyleLevel, StyleSource};
use super::Scene;
#[track_caller]
pub(super) fn select_id(app: &mut App, id: u32) {
select_row(app, &RowKey::Sheep(id));
}
#[track_caller]
pub(super) fn select_group(app: &mut App, name: &str) {
select_row(app, &RowKey::Group(name.to_string()));
}
#[track_caller]
pub(super) fn select_fold(app: &mut App, name: &str) {
select_row(app, &RowKey::Fold(name.to_string()));
}
#[track_caller]
fn select_row(app: &mut App, key: &RowKey) {
for _ in 0..=app.visible_rows().len() {
if app.selected().as_ref() == Some(key) {
return;
}
let target = app.visible_rows().iter().position(|row| row == key);
let step = match (app.selected_index(), target) {
(Some(current), Some(target)) if target < current => KeyPress::SelectUp,
_ => KeyPress::SelectDown,
};
app.update(Msg::Key(step));
}
panic!("the gallery cannot park its cursor on {key:?}");
}
pub(super) fn poll_twice(
app: &mut App,
anchor: Instant,
mut rows: Vec<ProcessInfo>,
deltas: &[(u32, u64, u64)],
) -> Vec<ProcessInfo> {
for &(id, first, _) in deltas {
if let Some(row) = rows.iter_mut().find(|row| row.id == id) {
row.cpu_ms = Some(first);
}
}
app.update(Msg::Snapshot {
rows: rows.clone(),
at: anchor - Duration::from_secs(2),
});
for &(id, _, second) in deltas {
if let Some(row) = rows.iter_mut().find(|row| row.id == id) {
row.cpu_ms = Some(second);
}
}
app.update(Msg::Snapshot {
rows: rows.clone(),
at: anchor,
});
rows
}
pub(super) fn feed_for(which: Scene) -> Tail {
match which {
Scene::Empty => Tail::default(),
Scene::WithDogs => Tail {
lines: [
"rotated /var/log/api/access.log -> access.log.1",
"compressed access.log.1 (4.2M -> 380K)",
"pruned 2 archives older than 14 days",
"next rotation in 6h",
]
.into_iter()
.map(|text| TailLine::new(Stream::Out, text))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 256,
note: None,
},
Scene::FeedGap => Tail {
lines: (0..30)
.map(|n| {
TailLine::new(
Stream::Out,
&format!("GET /v1/orders/{n} 200 {}ms", 8 + n % 40),
)
})
.collect(),
missed_lines: 500,
missed_bytes: 4_012_000,
read_bytes: 65_536,
note: None,
},
Scene::FeedMissing => Tail {
lines: Vec::new(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: Some("this sheep has not written a log in this $SHEP_HOME".to_string()),
},
Scene::Bleats => Tail {
lines: [
(Stream::Out, "listening on 0.0.0.0:8080"),
(
Stream::Err,
"WARN connection pool nearing capacity: 47/50 in use",
),
(Stream::Out, "GET /v1/orders 200 12ms"),
(
Stream::Out,
"WARN retrying upstream payment gateway after a timeout, backing off \
500ms before trying again with jitter added so the whole fleet does \
not retry at once",
),
(
Stream::Err,
"INFO shutting down worker 2 for a rolling restart",
),
(Stream::Out, "DEBUG cache warmed 128 keys in 4ms"),
(Stream::Out, "POST /v1/orders 201 88ms"),
(
Stream::Err,
"ERROR failed to write session cache: disk quota exceeded",
),
(Stream::Out, "connection pool: 14/50 in use"),
(
Stream::Out,
"ERROR panic recovered in worker 3: index out of bounds",
),
(Stream::Err, "GET /healthz 200 3ms"),
(Stream::Out, "GET /v1/orders/8821 200 9ms"),
(Stream::Err, "DEBUG flushing metrics buffer"),
(Stream::Out, "POST /v1/orders 500 210ms"),
(
Stream::Err,
"WARN queue depth crossed 200, backpressure engaged",
),
(Stream::Out, "GET /v1/orders/9013 200 7ms"),
]
.into_iter()
.map(|(stream, text)| TailLine::new(stream, text))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 2048,
note: None,
},
_ => Tail {
lines: [
"listening on 0.0.0.0:8080",
"GET /healthz 200 3ms",
"GET /v1/orders 200 44ms",
"POST /v1/orders 201 88ms",
"GET /v1/orders/8821 200 9ms",
"connection pool: 14/50 in use",
]
.into_iter()
.map(|text| TailLine::new(Stream::Out, text))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 512,
note: None,
},
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn sheep(
id: u32,
name: &str,
status: ProcStatus,
pid: Option<u32>,
restarts: u32,
cpu: Option<f32>,
memory: Option<u64>,
fold: Option<&str>,
) -> ProcessInfo {
ProcessInfo::builder(id, name, status)
.pid(pid)
.restarts(restarts)
.uptime_ms(4_512_000 + u64::from(id) * 91_000)
.cpu_percent(cpu)
.memory_bytes(memory)
.fold(fold.map(str::to_string))
.out_file(Some(format!("/home/ada/.shep/logs/{name}-{id}-out.log")))
.err_file(Some(format!("/home/ada/.shep/logs/{name}-{id}-err.log")))
.last_exit(match status {
ProcStatus::Errored | ProcStatus::WaitingRestart => Some(ExitInfo {
code: Some(1),
signal: None,
}),
ProcStatus::Stopped => Some(ExitInfo {
code: None,
signal: Some(15),
}),
ProcStatus::Online | ProcStatus::Starting | ProcStatus::Stopping => None,
_ => unreachable!("no scene depicts an unrecognized status"),
})
.build()
}
pub(super) fn sheep_with_ceiling(id: u32, name: &str, memory: u64, ceiling: u64) -> ProcessInfo {
let mut info = sheep(
id,
name,
ProcStatus::Online,
Some(48_300 + id),
0,
Some(2.0),
Some(memory),
Some("edge"),
);
info.max_memory = Some(ceiling);
info
}
pub(super) fn sheep_pane_config_view() -> 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::new(), Vec::new())
}
pub(super) fn instance(
id: u32,
name: &str,
slot: u32,
restarts: u32,
cpu: f32,
memory: u64,
uptime_ms: u64,
) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.instance(Some(slot))
.pid(Some(48_400 + id))
.restarts(restarts)
.uptime_ms(uptime_ms)
.cpu_percent(Some(cpu))
.memory_bytes(Some(memory))
.fold(Some("edge".to_string()))
.out_file(Some(format!("/home/ada/.shep/logs/{name}-{id}-out.log")))
.err_file(Some(format!("/home/ada/.shep/logs/{name}-{id}-err.log")))
.build()
}
pub(super) fn restarted_api() -> ProcessInfo {
sheep(
2,
"api",
ProcStatus::Online,
Some(48_299),
2,
Some(7.1),
Some(241 << 20),
Some("edge"),
)
}
pub(super) fn flock_without_api() -> Vec<ProcessInfo> {
vec![
sheep(
0,
"web",
ProcStatus::Online,
Some(48_211),
0,
Some(3.4),
Some(182 << 20),
Some("edge"),
),
sheep(
1,
"web",
ProcStatus::Online,
Some(48_212),
0,
Some(2.9),
Some(178 << 20),
Some("edge"),
),
sheep(
3,
"billing-reconciliation-worker",
ProcStatus::Online,
Some(48_230),
0,
Some(0.8),
Some(96 << 20),
None,
),
sheep(
4,
"cron",
ProcStatus::Online,
Some(48_233),
0,
Some(0.1),
Some(8 << 20),
None,
),
sheep(
5,
"metrics",
ProcStatus::Online,
Some(48_240),
0,
Some(0.4),
Some(11 << 20),
None,
),
]
}
pub(super) fn dog_sheep(
id: u32,
name: &str,
source: DogSource,
handshook: Option<bool>,
) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online)
.pid(Some(90_000 + id))
.dog(Some(source))
.handshook(handshook)
.build()
}
#[track_caller]
pub(super) fn move_settings_cursor_to(app: &mut App, key: &str) {
let field = shep_toml_shape()
.fields
.fields()
.iter()
.position(|field| field.key == key)
.expect("key is a field of the settings tree");
let target = app
.settings()
.expect("the settings screen must already be open")
.rows()
.iter()
.position(|row| *row == SettingsRow::Field(field))
.expect("the field is a row of the open tree");
for _ in 0..target {
app.update(Msg::Key(KeyPress::SelectDown));
}
}
pub(super) fn settings_snapshot_for_gallery() -> SettingsSnapshot {
let document = json!({
"daemon": {
"log_level": "warn",
"socket": "/home/ada/.shep/run/shep.sock",
"max_cron_sleep": "30s",
"environment": "production",
},
"style": { "level": "full" },
"interpreters": { "py": "python3" },
});
SettingsSnapshot {
document: document.as_object().cloned().unwrap_or_default(),
socket_default: PathBuf::from("/home/ada/.shep/run/shep.sock"),
style: (StyleLevel::Full, StyleSource::Config),
dogs: vec![
DogView::built_in("bark", false),
DogView::built_in("metrics", true),
],
}
}
pub(super) fn settings_snapshot_with_dog_drift() -> SettingsSnapshot {
SettingsSnapshot {
dogs: vec![
DogView {
env_keys: vec!["PADDOCK_KEY".to_owned(), "RUST_LOG".to_owned()],
..DogView::adopted("otel", false, Path::new("/usr/local/bin/shep-otel"))
},
DogView::adopted("ledger", true, Path::new("/opt/ledger/bin/dog")),
DogView::built_in("bark", true),
],
..settings_snapshot_for_gallery()
}
}
pub(super) fn edit_pane_config_view() -> SheepConfigView {
let mut config = AppConfig {
name: "api".to_string(),
script: "./api/server.js".to_string(),
args: vec!["--port".to_string(), "8080".to_string()],
max_restarts: 32,
instances: 1,
..AppConfig::default()
};
config
.env
.insert("NODE_ENV".to_string(), "production".to_string());
config
.env
.insert("DATABASE_URL".to_string(), "postgres://db/api".to_string());
SheepConfigView::new(config, Vec::new(), Vec::new())
}
pub(super) fn close_dialog_config_view() -> SheepConfigView {
let mut config = AppConfig {
name: "api".to_string(),
script: "./api/server.js".to_string(),
args: vec!["--port".to_string(), "8080".to_string()],
max_restarts: 32,
instances: 1,
..AppConfig::default()
};
config
.env
.insert("NODE_ENV".to_string(), "production".to_string());
config
.env
.insert("DATABASE_URL".to_string(), "postgres://db/api".to_string());
SheepConfigView::new(config, Vec::new(), vec!["listen_timeout".to_string()])
}
pub(super) fn jobs_schema() -> Value {
json!({
"$ref": "#/$defs/ProjectSettings",
"$defs": {
"ProjectSettings": {
"type": "object",
"properties": {
"concurrency": { "type": "integer" },
"merge": { "enum": ["ask", "auto"] },
"hours": { "$ref": "#/$defs/Hours" },
"models": { "$ref": "#/$defs/Models" },
},
},
"Hours": {
"type": "object",
"properties": {
"start": { "type": "string" },
"end": { "type": "string" },
},
},
"Models": {
"type": "object",
"properties": { "worker": { "$ref": "#/$defs/Worker" } },
},
"Worker": {
"type": "object",
"properties": {
"model": { "type": "string" },
"token": { "type": "string", "x-shep-secret": true },
},
},
},
})
}
pub(super) fn jobs_table() -> Map<String, Value> {
json!({
"concurrency": 2,
"merge": "ask",
"hours": { "start": "09:00", "end": "17:00" },
"models": { "worker": { "model": "small", "token": "sk-live-51Hx9Qa" } },
})
.as_object()
.cloned()
.expect("an object")
}
pub(super) fn dogs_listing() -> Vec<ListedDog> {
["jobs", "deploy"]
.into_iter()
.map(|name| ListedDog {
entry: SheepDogEntry {
name: name.to_string(),
adopted_path: Some(PathBuf::from(format!("/opt/{name}"))),
schema: None,
},
asked: false,
})
.collect()
}
pub(super) fn jobs_entry() -> SheepDogEntry {
SheepDogEntry {
name: "jobs".to_string(),
adopted_path: Some(PathBuf::from("/opt/jobs")),
schema: Some(jobs_schema()),
}
}
pub(super) fn kelpie_entry() -> SheepDogEntry {
SheepDogEntry {
name: "kelpie".to_string(),
adopted_path: Some(PathBuf::from("/opt/kelpie")),
schema: Some(crate::lookout::testing::kelpie_sheep_schema()),
}
}
pub(super) fn kelpie_config_view() -> SheepConfigView {
let mut view = edit_pane_config_view();
view.config.dogs.insert(
"kelpie".to_string(),
json!({
"skills": {
"implement": { "kind": "path", "path": "skills/mine" },
"review": { "kind": "plugin", "plugin": "superpowers", "skill": "code-review" },
},
})
.as_object()
.cloned()
.expect("an object")
.into(),
);
view
}
pub(super) fn sheep_dogs_config_view() -> SheepConfigView {
let mut view = edit_pane_config_view();
view.config
.dogs
.insert("jobs".to_string(), jobs_table().into());
view.config.dogs.insert(
"legacy".to_string(),
json!({ "url": "https://ops:hunter2@example.test" })
.as_object()
.cloned()
.expect("an object")
.into(),
);
view
}