Skip to main content

planarian_tray/
state.rs

1//! What the indicator shows: one level and one line, from the daemon's
2//! console report. "Needs attention" is `ConsoleReport::needs_attention`,
3//! the same condition that makes the console start visible.
4
5use 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    /// Nothing needs you.
11    Ok,
12    /// A change is being built or applied.
13    Working,
14    /// Something needs a look: an incident being repaired, an approval, a reverted change.
15    Attention,
16    /// Something needs a person: escalated, cooling down after failed repairs,
17    /// the recovery interlock, or the daemon cannot be reached.
18    Fault,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Summary {
23    pub level: Level,
24    /// One line for the tooltip and the menu.
25    pub line: String,
26}
27
28/// The indicator's state from the last answer (or the error reaching the daemon).
29pub 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        // "busy with … for 3 min: this is how things stood …" → the part before the colon.
68        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    /// §12f, I12: a change parked by design is not work in progress or a
127    /// fault; it says what it is waiting for.
128    #[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        // Attention or Fault exactly when the console would start visible.
216        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}