1mod 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
25pub const SURFACE: Color = color!(0x151719);
27pub 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 platform_specific: window::settings::PlatformSpecific {
43 application_id: "planarian".into(),
44 ..Default::default()
45 },
46 ..Default::default()
47 })
48 .run()
49}
50
51fn 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
67pub 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 answer: Option<String>,
104 first: bool,
105 show: bool,
107 started: Instant,
109 seconds: f32,
110 reduced_motion: bool,
112 graph_cache: canvas::Cache,
113}
114
115impl Console {
116 fn new() -> (Console, Task<Message>) {
117 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 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 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
326fn 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 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
496fn 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}