| @@ -0,0 +1,297 @@ |
| 1 | //! # terminal-pet |
| 2 | //! |
| 3 | //! The second face: a pet whose mood follows the shell and the machine. |
| 4 | //! |
| 5 | //! The garden renders the four git aggregates. Everything else on the bus — |
| 6 | //! the shell's keystroke rate and session length, the five hardware scalars — |
| 7 | //! had no reader at all, which meant the collectors producing them could stop |
| 8 | //! without anything visibly changing. This is their reader. |
| 9 | //! |
| 10 | //! It is also where the daemon's own health becomes visible. |
| 11 | //! [`SignalName::CollectorUp`] at `0.0` makes the pet sick, and that outranks |
| 12 | //! every other mood: a pet that cannot feel its own hardware should look wrong |
| 13 | //! rather than look calm. |
| 14 | //! |
| 15 | //! Stateless by design — [`PetState`] is a pure function of the current |
| 16 | //! snapshot, so there is no persistence to corrupt and nothing to migrate. |
| 17 | |
| 18 | use std::collections::BTreeMap; |
| 19 | |
| 20 | use signal_schema::{Signal, SignalName, Source}; |
| 21 | |
| 22 | /// A signal older than this is treated as stale: the collector may still be up |
| 23 | /// while the value behind it stopped moving. Comfortably past the daemon's 2s |
| 24 | /// default tick, so a healthy system never flickers. |
| 25 | pub const STALE_AFTER_MS: u64 = 30_000; |
| 26 | |
| 27 | /// Keystrokes per minute at or above which the pet reads as busy. |
| 28 | pub const BUSY_KEYS_PER_MIN: f64 = 60.0; |
| 29 | /// `ProcessInfo` thermal state at or above which the pet reads as overheating. |
| 30 | pub const HOT_THERMAL_STATE: f64 = 2.0; |
| 31 | /// Battery percentage below which an unplugged machine reads as flagging. |
| 32 | pub const LOW_BATTERY_PCT: f64 = 20.0; |
| 33 | |
| 34 | /// One metric as the pet sees it. |
| 35 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 36 | pub struct Reading { |
| 37 | pub value: f64, |
| 38 | /// The value has not been refreshed within [`STALE_AFTER_MS`]. Rendered |
| 39 | /// dimmed: "up but stalled" is not the same as "down". |
| 40 | pub stale: bool, |
| 41 | } |
| 42 | |
| 43 | /// What the pet is doing, in precedence order. |
| 44 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 45 | pub enum Mood { |
| 46 | /// A collector is down. Outranks everything else. |
| 47 | Sick, |
| 48 | Overheating, |
| 49 | LowBattery, |
| 50 | Busy, |
| 51 | Calm, |
| 52 | Sleeping, |
| 53 | } |
| 54 | |
| 55 | impl Mood { |
| 56 | pub fn face(self) -> &'static str { |
| 57 | match self { |
| 58 | Mood::Sick => "(x_x)", |
| 59 | Mood::Overheating => "(>_<)", |
| 60 | Mood::LowBattery => "(-_-)", |
| 61 | Mood::Busy => "(o_o)", |
| 62 | Mood::Calm => "(^_^)", |
| 63 | Mood::Sleeping => "(-.-)", |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | pub fn label(self) -> &'static str { |
| 68 | match self { |
| 69 | Mood::Sick => "sick", |
| 70 | Mood::Overheating => "overheating", |
| 71 | Mood::LowBattery => "flagging", |
| 72 | Mood::Busy => "busy", |
| 73 | Mood::Calm => "calm", |
| 74 | Mood::Sleeping => "asleep", |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// The pet, derived from one snapshot. |
| 80 | #[derive(Debug, Clone, PartialEq)] |
| 81 | pub struct PetState { |
| 82 | pub mood: Mood, |
| 83 | /// Collectors reporting down, in wire order. |
| 84 | pub down: Vec<Source>, |
| 85 | pub keys_per_min: Option<Reading>, |
| 86 | pub session_seconds: Option<Reading>, |
| 87 | pub cpu_load: Option<Reading>, |
| 88 | pub thermal_state: Option<Reading>, |
| 89 | pub battery_pct: Option<Reading>, |
| 90 | pub charging: Option<Reading>, |
| 91 | pub battery_draw_w: Option<Reading>, |
| 92 | } |
| 93 | |
| 94 | /// Fold a snapshot into the pet. |
| 95 | /// |
| 96 | /// `now_ms` is passed rather than read so the staleness rule is testable. |
| 97 | pub fn pet_from_signals(signals: &[Signal], now_ms: u64) -> PetState { |
| 98 | let mut latest: BTreeMap<(u8, u8), &Signal> = BTreeMap::new(); |
| 99 | for s in signals { |
| 100 | latest.insert((s.name.to_u8(), s.source.to_u8()), s); |
| 101 | } |
| 102 | |
| 103 | let read = |name: SignalName| -> Option<Reading> { |
| 104 | latest |
| 105 | .iter() |
| 106 | .find(|((n, _), _)| *n == name.to_u8()) |
| 107 | .map(|(_, s)| Reading { |
| 108 | value: s.value.0, |
| 109 | stale: now_ms.saturating_sub(s.ts) > STALE_AFTER_MS, |
| 110 | }) |
| 111 | }; |
| 112 | |
| 113 | let mut down: Vec<Source> = latest |
| 114 | .iter() |
| 115 | .filter(|((n, _), s)| *n == SignalName::CollectorUp.to_u8() && s.value.0 == 0.0) |
| 116 | .filter_map(|((_, src), _)| Source::from_u8(*src)) |
| 117 | .collect(); |
| 118 | down.sort_by_key(|s| s.to_u8()); |
| 119 | |
| 120 | let keys_per_min = read(SignalName::KeysPerMin); |
| 121 | let thermal_state = read(SignalName::ThermalState); |
| 122 | let battery_pct = read(SignalName::BatteryPct); |
| 123 | let charging = read(SignalName::Charging); |
| 124 | |
| 125 | let plugged_in = charging.map(|c| c.value >= 1.0).unwrap_or(false); |
| 126 | let mood = if !down.is_empty() { |
| 127 | Mood::Sick |
| 128 | } else if thermal_state.map(|t| t.value >= HOT_THERMAL_STATE).unwrap_or(false) { |
| 129 | Mood::Overheating |
| 130 | } else if battery_pct.map(|b| b.value < LOW_BATTERY_PCT).unwrap_or(false) && !plugged_in { |
| 131 | Mood::LowBattery |
| 132 | } else { |
| 133 | match keys_per_min.map(|k| k.value) { |
| 134 | Some(k) if k >= BUSY_KEYS_PER_MIN => Mood::Busy, |
| 135 | Some(k) if k > 0.0 => Mood::Calm, |
| 136 | _ => Mood::Sleeping, |
| 137 | } |
| 138 | }; |
| 139 | |
| 140 | PetState { |
| 141 | mood, |
| 142 | down, |
| 143 | keys_per_min, |
| 144 | session_seconds: read(SignalName::SessionSeconds), |
| 145 | cpu_load: read(SignalName::CpuLoad), |
| 146 | thermal_state, |
| 147 | battery_pct, |
| 148 | charging, |
| 149 | battery_draw_w: read(SignalName::BatteryDrawW), |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | /// Render the pet as a text block. |
| 154 | pub fn render(state: &PetState) -> String { |
| 155 | let mut out = String::from("terminal-pet\n============\n\n"); |
| 156 | out.push_str(&format!(" {} {}\n\n", state.mood.face(), state.mood.label())); |
| 157 | |
| 158 | let row = |label: &str, reading: Option<Reading>, unit: &str| -> String { |
| 159 | match reading { |
| 160 | None => format!(" {label:<16} —\n"), |
| 161 | Some(r) => { |
| 162 | let mark = if r.stale { " (stale)" } else { "" }; |
| 163 | format!(" {label:<16} {:.2}{unit}{mark}\n", r.value) |
| 164 | } |
| 165 | } |
| 166 | }; |
| 167 | out.push_str(&row("keys/min", state.keys_per_min, "")); |
| 168 | out.push_str(&row("session", state.session_seconds, "s")); |
| 169 | out.push_str(&row("cpu load", state.cpu_load, "")); |
| 170 | out.push_str(&row("thermal", state.thermal_state, "")); |
| 171 | out.push_str(&row("battery", state.battery_pct, "%")); |
| 172 | out.push_str(&row("draw", state.battery_draw_w, "W")); |
| 173 | |
| 174 | if !state.down.is_empty() { |
| 175 | out.push_str("\n collectors down:"); |
| 176 | for source in &state.down { |
| 177 | out.push_str(&format!(" {source:?}")); |
| 178 | } |
| 179 | out.push('\n'); |
| 180 | } |
| 181 | out |
| 182 | } |
| 183 | |
| 184 | #[cfg(test)] |
| 185 | mod tests { |
| 186 | use super::*; |
| 187 | use signal_schema::{Value, SCHEMA_VERSION}; |
| 188 | |
| 189 | fn sig(source: Source, name: SignalName, value: f64, ts: u64) -> Signal { |
| 190 | Signal { |
| 191 | schema_version: SCHEMA_VERSION, |
| 192 | ts, |
| 193 | source, |
| 194 | name, |
| 195 | value: Value(value), |
| 196 | tag: None, |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | const NOW: u64 = 1_000_000; |
| 201 | |
| 202 | fn typing(keys: f64) -> Signal { |
| 203 | sig(Source::Terminal, SignalName::KeysPerMin, keys, NOW) |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn typing_drives_energy() { |
| 208 | assert_eq!(pet_from_signals(&[typing(0.0)], NOW).mood, Mood::Sleeping); |
| 209 | assert_eq!(pet_from_signals(&[typing(10.0)], NOW).mood, Mood::Calm); |
| 210 | assert_eq!(pet_from_signals(&[typing(120.0)], NOW).mood, Mood::Busy); |
| 211 | } |
| 212 | |
| 213 | #[test] |
| 214 | fn no_terminal_signal_at_all_is_asleep_not_sick() { |
| 215 | assert_eq!(pet_from_signals(&[], NOW).mood, Mood::Sleeping); |
| 216 | } |
| 217 | |
| 218 | #[test] |
| 219 | fn heat_outranks_typing() { |
| 220 | let signals = [ |
| 221 | typing(120.0), |
| 222 | sig(Source::Macos, SignalName::ThermalState, 3.0, NOW), |
| 223 | ]; |
| 224 | assert_eq!(pet_from_signals(&signals, NOW).mood, Mood::Overheating); |
| 225 | } |
| 226 | |
| 227 | #[test] |
| 228 | fn a_low_battery_only_counts_when_unplugged() { |
| 229 | let low = sig(Source::Macos, SignalName::BatteryPct, 5.0, NOW); |
| 230 | let plugged = sig(Source::Macos, SignalName::Charging, 1.0, NOW); |
| 231 | let unplugged = sig(Source::Macos, SignalName::Charging, 0.0, NOW); |
| 232 | |
| 233 | assert_eq!( |
| 234 | pet_from_signals(&[typing(1.0), low.clone(), unplugged], NOW).mood, |
| 235 | Mood::LowBattery |
| 236 | ); |
| 237 | assert_eq!( |
| 238 | pet_from_signals(&[typing(1.0), low, plugged], NOW).mood, |
| 239 | Mood::Calm, |
| 240 | "on the charger a low battery is not a worry" |
| 241 | ); |
| 242 | } |
| 243 | |
| 244 | /// The point of putting health on the bus: a dead collector is visible in |
| 245 | /// the face, not just in a log nobody reads. |
| 246 | #[test] |
| 247 | fn a_down_collector_outranks_every_other_mood() { |
| 248 | let signals = [ |
| 249 | typing(120.0), |
| 250 | sig(Source::Macos, SignalName::ThermalState, 3.0, NOW), |
| 251 | sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW), |
| 252 | ]; |
| 253 | let pet = pet_from_signals(&signals, NOW); |
| 254 | assert_eq!(pet.mood, Mood::Sick); |
| 255 | assert_eq!(pet.down, vec![Source::Hardware]); |
| 256 | assert!(render(&pet).contains("collectors down: Hardware")); |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn a_healthy_collector_is_not_listed_as_down() { |
| 261 | let signals = [ |
| 262 | typing(5.0), |
| 263 | sig(Source::Hardware, SignalName::CollectorUp, 1.0, NOW), |
| 264 | ]; |
| 265 | let pet = pet_from_signals(&signals, NOW); |
| 266 | assert_eq!(pet.mood, Mood::Calm); |
| 267 | assert!(pet.down.is_empty()); |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | fn health_is_tracked_per_source() { |
| 272 | let signals = [ |
| 273 | sig(Source::Git, SignalName::CollectorUp, 1.0, NOW), |
| 274 | sig(Source::Terminal, SignalName::CollectorUp, 0.0, NOW), |
| 275 | sig(Source::Hardware, SignalName::CollectorUp, 0.0, NOW), |
| 276 | ]; |
| 277 | let pet = pet_from_signals(&signals, NOW); |
| 278 | assert_eq!(pet.down, vec![Source::Terminal, Source::Hardware]); |
| 279 | } |
| 280 | |
| 281 | /// Up but stalled is not the same as down: the collector never reported a |
| 282 | /// failure, the number behind it simply stopped moving. |
| 283 | #[test] |
| 284 | fn an_old_value_is_stale_but_not_sick() { |
| 285 | let old = sig( |
| 286 | Source::Hardware, |
| 287 | SignalName::CpuLoad, |
| 288 | 0.5, |
| 289 | NOW - STALE_AFTER_MS - 1, |
| 290 | ); |
| 291 | let pet = pet_from_signals(&[typing(5.0), old], NOW); |
| 292 | assert!(pet.cpu_load.expect("cpu load").stale); |
| 293 | assert!(!pet.keys_per_min.expect("keys").stale); |
| 294 | assert_eq!(pet.mood, Mood::Calm, "stale is not sick"); |
| 295 | assert!(render(&pet).contains("(stale)")); |
| 296 | } |
| 297 | } |