Skip to main content

planarian_console/
main.rs

1//! `planarian-console`: the read-only visual console (SPEC §16a).
2//!
3//! Views: Graph (desired, observed and health per unit as three indicators;
4//! edge style by kind; suppressed units hatched; Unknown units grey with a
5//! stale-clock mark), Timeline (transactions with their journal states),
6//! Generations, Incidents. Buttons: undo, override and release, which the
7//! daemon runs as Runtime transitions with the user as the actor. It reads
8//! only the daemon's socket. It starts minimized unless something needs
9//! attention: an open incident, a pending approval, a reconciliation record,
10//! or a reverted change (`--show`, which the status indicator passes, keeps
11//! it up regardless).
12
13mod graph;
14mod mark;
15
16use iced::widget::{button, canvas, column, container, row, rule, scrollable, text};
17use iced::{Color, Element, Fill, Subscription, Task, Theme, color, time, window};
18use mark::{Mark, Motion};
19use planarian_ipc::{ConsoleReport, Request, Response, SOCKET, status_words, waiting_words};
20use planarian_schema::{IncidentState, UnitId, Until};
21use std::io::{BufRead, BufReader, Write};
22use std::os::unix::net::UnixStream;
23use std::time::{Duration, Instant};
24
25/// The identity kit's charcoal surface.
26pub const SURFACE: Color = color!(0x151719);
27/// A raised surface (graph nodes, panels) on the charcoal.
28pub const RAISED: Color = color!(0x222529);
29pub const TEXT: Color = color!(0xeceeef);
30pub const MUTED: Color = color!(0x9a9fa5);
31
32fn main() -> iced::Result {
33    let icon = window::icon::from_rgba(include_bytes!("icon-64.rgba").to_vec(), 64, 64).ok();
34    iced::application(Console::new, Console::update, Console::view)
35        .title("planarian")
36        .subscription(Console::subscription)
37        .theme(Console::theme)
38        .window(window::Settings {
39            size: iced::Size::new(1100.0, 720.0),
40            icon,
41            // Matches planarian.desktop, so the shell shows the right icon.
42            platform_specific: window::settings::PlatformSpecific {
43                application_id: "planarian".into(),
44                ..Default::default()
45            },
46            ..Default::default()
47        })
48        .run()
49}
50
51/// Dark by default: the kit is monochrome on charcoal. Status colours stay
52/// (health is not identity), toned for a dark surface.
53fn dark() -> Theme {
54    Theme::custom(
55        "planarian dark",
56        iced::theme::Palette {
57            background: SURFACE,
58            text: TEXT,
59            primary: color!(0xe8eaec),
60            success: color!(0x3fb56a),
61            warning: color!(0xe0a43a),
62            danger: color!(0xe0524f),
63        },
64    )
65}
66
67/// One request to the daemon, one answer.
68pub fn call(req: &Request) -> Result<Response, String> {
69    let path = std::env::var("PLANARIAN_SOCKET").unwrap_or_else(|_| SOCKET.into());
70    let mut s = UnixStream::connect(&path).map_err(|e| format!("{path}: {e}"))?;
71    let _ = s.set_read_timeout(Some(Duration::from_secs(10)));
72    s.write_all(req.encode().as_bytes()).map_err(|e| e.to_string())?;
73    let mut line = String::new();
74    BufReader::new(s).read_line(&mut line).map_err(|e| e.to_string())?;
75    serde_json::from_str(&line).map_err(|e| format!("bad answer: {e}"))
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79enum View {
80    Graph,
81    Timeline,
82    Generations,
83    Incidents,
84}
85
86#[derive(Clone, Debug)]
87enum Message {
88    Tick,
89    Frame,
90    Show(View),
91    Select(UnitId),
92    Undo,
93    Suppress(UnitId),
94    LiftOverride(UnitId),
95}
96
97struct Console {
98    view: View,
99    report: Option<ConsoleReport>,
100    error: Option<String>,
101    selected: Option<UnitId>,
102    /// The answer to the last button.
103    answer: Option<String>,
104    first: bool,
105    /// `--show` (from the tray): stay up even when nothing needs attention.
106    show: bool,
107    /// The animation clock.
108    started: Instant,
109    seconds: f32,
110    /// `PLANARIAN_REDUCED_MOTION=1`: show the still mark instead of motion.
111    reduced_motion: bool,
112    graph_cache: canvas::Cache,
113}
114
115impl Console {
116    fn new() -> (Console, Task<Message>) {
117        // `--view timeline|generations|incidents` opens on that view.
118        let arg = std::env::args().skip_while(|a| a != "--view").nth(1).unwrap_or_default();
119        let view = match arg.as_str() {
120            "timeline" => View::Timeline,
121            "generations" => View::Generations,
122            "incidents" => View::Incidents,
123            _ => View::Graph,
124        };
125        let reduced_motion = std::env::var("PLANARIAN_REDUCED_MOTION").is_ok_and(|v| v != "0" && !v.is_empty());
126        let c = Console {
127            view,
128            report: None,
129            error: None,
130            selected: None,
131            answer: None,
132            first: true,
133            show: std::env::args().any(|a| a == "--show"),
134            started: Instant::now(),
135            seconds: 0.0,
136            reduced_motion,
137            graph_cache: canvas::Cache::new(),
138        };
139        (c, Task::done(Message::Tick))
140    }
141
142    fn theme(&self) -> Theme {
143        dark()
144    }
145
146    fn subscription(&self) -> Subscription<Message> {
147        let tick = time::every(Duration::from_secs(2)).map(|_| Message::Tick);
148        if self.motion() == Motion::Still || self.reduced_motion {
149            tick
150        } else {
151            Subscription::batch([tick, time::every(Duration::from_millis(60)).map(|_| Message::Frame)])
152        }
153    }
154
155    /// What the daemon is doing, if anything: "loading" while there is no
156    /// answer yet, "working" while a transaction is in flight or the daemon
157    /// answers from its busy snapshot.
158    fn activity(&self) -> Option<(Motion, String)> {
159        let Some(r) = &self.report else {
160            return self.error.is_none().then(|| (Motion::Loading, "connecting to planariad…".to_string()));
161        };
162        let busy = r.status.notices.iter().find(|n| n.starts_with("busy with"));
163        if let Some(n) = busy {
164            let what = n.split(':').next().unwrap_or(n);
165            return Some((Motion::Working, format!("{what}. Showing how things stood when it started.")));
166        }
167        if let Some(n) = r.status.notices.iter().find(|n| n.starts_with("building ")) {
168            return Some((Motion::Working, n.clone()));
169        }
170        r.status.in_flight.first().map(|(t, state, what)| match waiting_words(state) {
171            Some(w) => (Motion::Still, format!("#{} is {w}: {what}", t.0)),
172            None => (Motion::Working, format!("working on #{}: {what} ({})", t.0, planarian_state_name(state))),
173        })
174    }
175
176    fn motion(&self) -> Motion {
177        self.activity().map(|a| a.0).unwrap_or(Motion::Still)
178    }
179
180    fn mark(&self, size: f32, motion: Motion) -> Element<'_, Message> {
181        let motion = if self.reduced_motion { Motion::Still } else { motion };
182        canvas(Mark { motion, seconds: self.seconds, color: TEXT }).width(size).height(size).into()
183    }
184
185    fn refresh(&mut self) {
186        match call(&Request::Console) {
187            Ok(Response::Console(r)) => {
188                if self.report.as_ref() != Some(&*r) {
189                    self.graph_cache.clear();
190                }
191                self.report = Some(*r);
192                self.error = None;
193            }
194            Ok(other) => self.error = Some(format!("unexpected answer: {other:?}")),
195            Err(e) => self.error = Some(format!("cannot reach planariad: {e}")),
196        }
197    }
198
199    fn act(&mut self, req: Request) {
200        self.answer = Some(match call(&req) {
201            Ok(r) => describe(&r),
202            Err(e) => e,
203        });
204        self.refresh();
205    }
206
207    fn update(&mut self, m: Message) -> Task<Message> {
208        match m {
209            Message::Tick => {
210                self.refresh();
211                if std::mem::take(&mut self.first) && !self.show && !self.needs_attention() {
212                    return window::latest().and_then(|id| window::minimize(id, true));
213                }
214            }
215            Message::Frame => self.seconds = self.started.elapsed().as_secs_f32(),
216            Message::Show(v) => self.view = v,
217            Message::Select(u) => {
218                self.selected = Some(u);
219                self.graph_cache.clear();
220            }
221            Message::Undo => self.act(Request::Undo),
222            Message::Suppress(unit) => self.act(Request::SetOverride {
223                unit,
224                until: Until::Explicit,
225                desired: planarian_schema::OverrideDesired::LeaveAlone,
226            }),
227            Message::LiftOverride(unit) => self.act(Request::LiftOverride { unit }),
228        }
229        Task::none()
230    }
231
232    /// §16a: start visible only when something needs attention (the tray
233    /// turns amber or red on the same condition).
234    fn needs_attention(&self) -> bool {
235        self.report.as_ref().is_none_or(ConsoleReport::needs_attention)
236    }
237
238    fn view(&self) -> Element<'_, Message> {
239        let tab = |label: &'static str, v: View| {
240            let b = button(text(label)).on_press(Message::Show(v));
241            if self.view == v { b.style(button::primary) } else { b.style(button::secondary) }
242        };
243        let header = row![
244            self.mark(28.0, Motion::Still),
245            text("planarian").size(20),
246            iced::widget::space::horizontal().width(12),
247            tab("Graph", View::Graph),
248            tab("Timeline", View::Timeline),
249            tab("Generations", View::Generations),
250            tab("Incidents", View::Incidents),
251            iced::widget::space::horizontal(),
252            button(text("Undo last change")).on_press(Message::Undo).style(button::secondary),
253        ]
254        .spacing(8)
255        .align_y(iced::Alignment::Center);
256        let mut page = column![header].spacing(10).padding(12);
257        if let Some((motion, what)) = self.activity() {
258            page = page.push(
259                container(
260                    row![self.mark(40.0, motion), text(what).size(14)].spacing(12).align_y(iced::Alignment::Center),
261                )
262                .padding(6)
263                .width(Fill)
264                .style(container::rounded_box),
265            );
266        }
267        if let Some(e) = &self.error {
268            page = page.push(banner(e.clone(), true));
269        }
270        if let Some(r) = &self.report {
271            for b in banners(r) {
272                page = page.push(b);
273            }
274        }
275        if let Some(a) = &self.answer {
276            page = page.push(text(a.clone()).size(14));
277        }
278        page = page.push(rule::horizontal(1));
279        let body: Element<'_, Message> = match (&self.report, self.view) {
280            (None, _) => text("").into(),
281            (Some(r), View::Graph) => self.graph_view(r),
282            (Some(r), View::Timeline) => timeline(r),
283            (Some(r), View::Generations) => generations(r),
284            (Some(r), View::Incidents) => incidents(r),
285        };
286        page.push(body).into()
287    }
288
289    fn graph_view<'a>(&'a self, r: &'a ConsoleReport) -> Element<'a, Message> {
290        let canvas = canvas(graph::Graph { report: r, selected: self.selected.as_ref(), cache: &self.graph_cache })
291            .width(Fill)
292            .height(Fill);
293        let side: Element<'_, Message> =
294            match self.selected.as_ref().and_then(|u| r.nodes.iter().find(|n| &n.unit == u)) {
295                None => column![text("Select a unit.").size(14), text(graph::LEGEND).size(12),].spacing(8).into(),
296                Some(n) => {
297                    let action = if n.suppressed {
298                        button(text("Let it run again")).on_press(Message::LiftOverride(n.unit.clone()))
299                    } else {
300                        button(text("Leave it alone")).on_press(Message::Suppress(n.unit.clone()))
301                    };
302                    column![
303                        text(n.unit.to_string()).size(16),
304                        text(format!("desired: {}{}", n.desired, if n.expected { "" } else { " (not now)" })).size(14),
305                        text(format!("observed: {}", n.observed)).size(14),
306                        text(format!("health: {}", status_words(&n.health))).size(14),
307                        action,
308                        text(graph::LEGEND).size(12),
309                    ]
310                    .spacing(8)
311                    .into()
312                }
313            };
314        row![canvas, container(side).width(280)].spacing(12).into()
315    }
316}
317
318fn banner<'a>(s: String, warn: bool) -> Element<'a, Message> {
319    container(text(s).size(14))
320        .padding(8)
321        .width(Fill)
322        .style(if warn { container::danger } else { container::rounded_box })
323        .into()
324}
325
326/// A change waiting for approval, with what it would change (§16
327/// *Approvals show the change*; the core computed the diff).
328fn pending_text(p: &planarian_ipc::Pending) -> String {
329    let mut t = format!("Waiting for approval: {} (as root: planarian approve {})", p.what, p.approve);
330    if !p.diff.is_empty() {
331        t.push_str("\nIt would change:");
332        for l in p.diff.lines() {
333            t.push_str("\n  ");
334            t.push_str(&l);
335        }
336    }
337    t
338}
339
340fn banners<'a>(r: &ConsoleReport) -> Vec<Element<'a, Message>> {
341    let s = &r.status;
342    let mut out = vec![];
343    for (t, why) in &s.interlock {
344        out.push(banner(
345            format!(
346                "Recovery failed (transaction {}): {why}. Automatic repair is stopped until an administrator runs `planarian resolve {}`.",
347                t.0, t.0
348            ),
349            true,
350        ));
351    }
352    for p in &s.pending {
353        out.push(banner(pending_text(p), false));
354    }
355    // §11 (B-11): stops taken as someone's intent, until a user acknowledges.
356    for (u, why) in &s.inferred {
357        out.push(banner(
358            format!("Left stopped: {u} was {why}; Planarian took that as intended. If it was not, start it again; `planarian ack` once seen."),
359            false,
360        ));
361    }
362    if let Some(n) = &s.baseline_note {
363        out.push(banner(n.clone(), false));
364    }
365    if r.last_change_reverted {
366        out.push(banner("The last configuration change was reverted; see the Timeline.".into(), false));
367    }
368    if let Some(sp) = &s.space
369        && let Some(short) = sp.shortfall
370    {
371        out.push(banner(
372            format!(
373                "Generations are {:.1} GB over their space budget; Planarian cannot collect the ones it must keep, so the budget is a target, not a guarantee.",
374                short as f64 / 1e9
375            ),
376            false,
377        ));
378    }
379    if let Some(rec) = &s.reconciliation
380        && !rec.is_empty()
381    {
382        out.push(banner(format!("After the last restart: {}", rec.join("; ")), false));
383    }
384    out
385}
386
387fn timeline<'a>(r: &'a ConsoleReport) -> Element<'a, Message> {
388    let mut list = column![].spacing(6);
389    if r.timeline.is_empty() {
390        list = list.push(text("No transactions yet."));
391    }
392    for t in &r.timeline {
393        let who = if t.by_watchdog { " · finished by the watchdog" } else { "" };
394        let live = if t.live { " · in progress" } else { "" };
395        let why = if t.why.is_empty() { String::new() } else { format!("\n    because {}", t.why) };
396        list = list.push(
397            text(format!(
398                "#{}  {} — {}{live}{who}\n    {}{why}",
399                t.txn.0,
400                t.what,
401                planarian_state_name(&t.state),
402                t.path.join(" → ")
403            ))
404            .size(14),
405        );
406    }
407    scrollable(list).height(Fill).into()
408}
409
410fn planarian_state_name(s: &planarian_schema::TxnState) -> String {
411    match s {
412        planarian_schema::TxnState::Rejected(r) => format!("Rejected ({r:?})"),
413        planarian_schema::TxnState::Cancelled(a) => format!("Cancelled by {a:?}"),
414        other => format!("{other:?}"),
415    }
416}
417
418fn generations<'a>(r: &'a ConsoleReport) -> Element<'a, Message> {
419    let s = &r.status;
420    let g = |o: Option<planarian_schema::GenerationId>| o.map(|g| g.0.to_string()).unwrap_or_else(|| "—".into());
421    let confirmed = |o: Option<planarian_schema::GenerationId>| {
422        if o.is_some() && o == r.last_booted { " (boot-confirmed: running since boot)" } else { "" }
423    };
424    let mut rows = column![
425        text(format!("current: {}{}", s.generation.0, confirmed(Some(s.generation)))).size(15),
426        text(format!("previous: {}", g(r.previous))).size(15),
427        text(format!(
428            "known good: {}{}",
429            g(s.known_good),
430            if s.known_good.is_some() { " (boot-confirmed)" } else { "" }
431        ))
432        .size(15),
433        text(format!("live-tested, not yet rebooted: {}", g(s.live_tested))).size(15),
434        text(format!("accepted baseline: {}", g(s.accepted_baseline))).size(15),
435        text(format!("boots by default: {}", g(s.boot_default))).size(15),
436        text(format!("last booted: {}", g(r.last_booted))).size(15),
437    ]
438    .spacing(6);
439    for c in &r.candidates {
440        rows = rows.push(text(format!("candidate in progress: {}", c.0)).size(15));
441    }
442    if let Some(sp) = &s.space {
443        rows = rows.push(
444            text(format!(
445                "space: {} generation(s), {:.1} GB of a {:.1} GB budget",
446                sp.generations,
447                sp.bytes as f64 / 1e9,
448                sp.max_bytes as f64 / 1e9
449            ))
450            .size(15),
451        );
452    }
453    rows.into()
454}
455
456fn incidents<'a>(r: &'a ConsoleReport) -> Element<'a, Message> {
457    let mut list = column![].spacing(6);
458    if r.status.incidents.is_empty() {
459        list = list.push(text("No open incidents."));
460    }
461    for i in &r.status.incidents {
462        let state = match &i.state {
463            IncidentState::Cooldown { until } => format!("cooling down for {}", minutes(until.0, r.now.0)),
464            IncidentState::Escalated { until, .. } => {
465                format!("needs a person (no automatic action for {})", minutes(until.0, r.now.0))
466            }
467            IncidentState::Open => "open".into(),
468            s => format!("{s:?}").to_lowercase(),
469        };
470        list = list.push(
471            text(format!(
472                "#{}  {} — {}\n    {state}; {} attempt(s)",
473                i.id.0,
474                i.target,
475                status_words(&i.divergence.1),
476                i.attempts.len()
477            ))
478            .size(14),
479        );
480    }
481    scrollable(list).height(Fill).into()
482}
483
484fn describe(r: &Response) -> String {
485    match r {
486        Response::Done { txn, state, message } => format!("transaction {}: {state:?}. {message}", txn.0),
487        Response::NeedsApproval { what, approve, .. } => {
488            format!("{what} needs approval (as root: planarian approve {approve})")
489        }
490        Response::Rejected { reason, message } => format!("refused ({reason}): {message}"),
491        Response::Ok { message } | Response::Error { message } | Response::Unmatched { message } => message.clone(),
492        other => format!("{other:?}"),
493    }
494}
495
496/// "12 min", "1 h 5 min": from `now` until `until`, both in milliseconds.
497fn minutes(until: u64, now: u64) -> String {
498    let m = until.saturating_sub(now).div_ceil(60_000);
499    if m >= 60 { format!("{} h {} min", m / 60, m % 60) } else { format!("{m} min") }
500}