1use planarian_ipc::{ConsoleReport, status_words, waiting_words};
6use planarian_schema::IncidentState;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9pub enum Level {
10 Ok,
12 Working,
14 Attention,
16 Fault,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Summary {
23 pub level: Level,
24 pub line: String,
26}
27
28pub fn classify(report: Result<&ConsoleReport, &str>) -> Summary {
30 let r = match report {
31 Ok(r) => r,
32 Err(e) => return Summary { level: Level::Fault, line: format!("can't reach planariad ({e})") },
33 };
34 let s = &r.status;
35 if let Some((t, why)) = s.interlock.first() {
36 return Summary {
37 level: Level::Fault,
38 line: format!("recovery failed (transaction {}): {why}; automatic repair is stopped", t.0),
39 };
40 }
41 let held = s
42 .incidents
43 .iter()
44 .find(|i| matches!(i.state, IncidentState::Escalated { .. } | IncidentState::Cooldown { .. }));
45 if let Some(i) = held {
46 return Summary {
47 level: Level::Fault,
48 line: format!("{} needs a person: {}", i.target, status_words(&i.divergence.1)),
49 };
50 }
51 if r.needs_attention() {
52 let line = if let Some(p) = s.pending.first() {
53 format!("waiting for your approval: {}", p.what)
54 } else if let Some(i) = s.incidents.iter().find(|i| i.state != IncidentState::Closed) {
55 format!("{}: {}; repairing", i.target, status_words(&i.divergence.1))
56 } else if r.last_change_reverted {
57 "the last configuration change was reverted".into()
58 } else if let Some(rec) = s.reconciliation.as_ref().filter(|a| !a.is_empty()) {
59 format!("after the last restart: {}", rec.join("; "))
60 } else {
61 "something needs a look".into()
62 };
63 return Summary { level: Level::Attention, line };
64 }
65 let busy = s.notices.iter().find(|n| n.starts_with("building ") || n.starts_with("busy with "));
66 if let Some(n) = busy {
67 let line = if n.starts_with("busy with ") { n.split(':').next().unwrap_or(n) } else { n };
69 return Summary { level: Level::Working, line: line.to_string() };
70 }
71 if let Some((t, state, what)) = s.in_flight.first() {
72 return match waiting_words(state) {
73 Some(w) => Summary { level: Level::Ok, line: format!("#{} is {w}: {what}", t.0) },
74 None => Summary { level: Level::Working, line: format!("working on #{}: {what}", t.0) },
75 };
76 }
77 let line = match &s.baseline_note {
78 Some(_) => "all healthy (no baseline yet)".to_string(),
79 None => "all healthy".to_string(),
80 };
81 Summary { level: Level::Ok, line }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use planarian_ipc::{Pending, StatusReport};
88 use planarian_schema::*;
89
90 fn report() -> ConsoleReport {
91 let status: StatusReport = serde_json::from_value(serde_json::json!({
92 "generation": 1, "known_good": 1, "live_tested": null, "accepted_baseline": null,
93 "boot_default": 1, "baseline_note": null, "units": [], "incidents": [], "pending": [],
94 "in_flight": [], "interlock": [], "reconciliation": null, "notices": []
95 }))
96 .unwrap();
97 ConsoleReport {
98 status,
99 nodes: vec![],
100 edges: vec![],
101 timeline: vec![],
102 previous: None,
103 last_booted: Some(GenerationId(1)),
104 candidates: vec![],
105 last_change_reverted: false,
106 now: Timestamp(0),
107 }
108 }
109
110 fn incident(state: IncidentState) -> Incident {
111 Incident {
112 id: IncidentId(1),
113 kind: IncidentKind::Repair,
114 target: UnitId::service("gym-state"),
115 divergence: (DesiredStatus::Running, UnitStatus::Failed(Reason::ExitCode(1))),
116 opened: Timestamp(0),
117 attempts: vec![],
118 budget_left: 3,
119 state,
120 backoff: Millis::secs(10),
121 next_attempt: Timestamp(0),
122 regressions: 0,
123 }
124 }
125
126 #[test]
129 fn a_change_waiting_for_a_restart_is_ok_and_says_so() {
130 let mut r = report();
131 r.status.in_flight.push((TxnId(4), TxnState::AwaitingBoot, "apply kernel-param".into()));
132 let s = classify(Ok(&r));
133 assert_eq!(s.level, Level::Ok);
134 assert_eq!(s.line, "#4 is waiting for a restart: apply kernel-param");
135 }
136
137 #[test]
138 fn healthy_is_ok_and_says_so() {
139 let r = report();
140 assert_eq!(classify(Ok(&r)), Summary { level: Level::Ok, line: "all healthy".into() });
141 let mut r = report();
142 r.status.baseline_note = Some("no baseline yet: …".into());
143 let s = classify(Ok(&r));
144 assert_eq!(s.level, Level::Ok, "the first 10 minutes are not a problem");
145 assert!(s.line.contains("no baseline yet"));
146 }
147
148 #[test]
149 fn an_incident_being_repaired_is_attention_and_a_held_one_is_a_fault() {
150 let mut r = report();
151 r.status.incidents.push(incident(IncidentState::Open));
152 let s = classify(Ok(&r));
153 assert_eq!(s.level, Level::Attention);
154 assert_eq!(s.line, "service:gym-state: failed (exit code 1); repairing");
155 r.status.incidents[0].state = IncidentState::Cooldown { until: Timestamp(60_000) };
156 let s = classify(Ok(&r));
157 assert_eq!(s.level, Level::Fault);
158 assert_eq!(s.line, "service:gym-state needs a person: failed (exit code 1)");
159 }
160
161 #[test]
162 fn approvals_reverts_and_the_interlock() {
163 let mut r = report();
164 r.status.pending.push(Pending {
165 txn: TxnId(4),
166 approve: Hash::ZERO,
167 what: "apply service-enable service=sshd enabled=false".into(),
168 facts: DerivedFacts {
169 affected_units: vec![],
170 sensitivity: Sensitivities::default(),
171 disruption: Disruptions::of(Disruption::Brief),
172 recovery: Recovery::Revertible,
173 touches_manifest: false,
174 touches_canonical_probe: false,
175 changes_desired: true,
176 below_security_floor: false,
177 restores_unattributed_stop: false,
178 restarts_failed_unit: false,
179 removes_sensitivity: Default::default(),
180 removes: Default::default(),
181 removes_each: Default::default(),
182 restarts_held_unit: false,
183 other_sessions_affected: false,
184 affected_sessions: Vec::new(),
185 touches_core: false,
186 caller: Actor::user(1000),
187 config_diff: None,
188 },
189 issued: Timestamp(0),
190 diff: Default::default(),
191 });
192 assert_eq!(classify(Ok(&r)).line, "waiting for your approval: apply service-enable service=sshd enabled=false");
193 let mut r = report();
194 r.last_change_reverted = true;
195 assert_eq!(classify(Ok(&r)).level, Level::Attention);
196 r.status.interlock.push((TxnId(7), "revert failed".into()));
197 assert_eq!(classify(Ok(&r)).level, Level::Fault);
198 }
199
200 #[test]
201 fn building_is_working_and_an_unreachable_daemon_is_a_fault() {
202 let mut r = report();
203 r.status.notices.push("building apply font-scale scale=1.25 (3 min so far); repairs go on meanwhile".into());
204 let s = classify(Ok(&r));
205 assert_eq!(s.level, Level::Working);
206 assert_eq!(s.line, "building apply font-scale scale=1.25 (3 min so far); repairs go on meanwhile");
207 r.status.notices[0] =
208 "busy with the change font-scale for 3 min: this is how things stood when it started".into();
209 assert_eq!(classify(Ok(&r)).line, "busy with the change font-scale for 3 min");
210 assert_eq!(classify(Err("connection refused")).level, Level::Fault);
211 }
212
213 #[test]
214 fn the_tray_and_the_console_agree() {
215 let changes: [fn(&mut ConsoleReport); 3] = [
217 |r| r.last_change_reverted = true,
218 |r| r.status.reconciliation = Some(vec!["finished it: Committed".into()]),
219 |r| r.status.incidents.push(incident(IncidentState::Open)),
220 ];
221 for f in changes {
222 let mut r = report();
223 assert!(classify(Ok(&r)).level < Level::Attention && !r.needs_attention());
224 f(&mut r);
225 assert!(classify(Ok(&r)).level >= Level::Attention && r.needs_attention());
226 }
227 }
228}