Skip to main content

planarian_console/
graph.rs

1//! The Graph view: units laid out in layers by dependency, each with three
2//! indicators (desired, observed, health), edges styled by kind.
3
4use crate::{MUTED, Message, RAISED, TEXT};
5use iced::mouse;
6use iced::widget::canvas::{self, Frame, Geometry, LineDash, Path, Stroke, Text};
7use iced::{Color, Point, Rectangle, Renderer, Size, Theme};
8use planarian_ipc::{ConsoleReport, GraphNode};
9use planarian_schema::{EdgeKind, UnitId, UnitStatus};
10use std::collections::BTreeMap;
11
12pub const LEGEND: &str = "Each unit shows three dots: desired · observed · health.\n\
13    Lines: solid requires, dashed wants, dotted ordering only, dash-dot diagnostic.\n\
14    Hatched: left alone (suppressed). Grey with a clock: no fresh evidence.";
15
16const NODE: Size = Size::new(170.0, 46.0);
17
18pub struct Graph<'a> {
19    pub report: &'a ConsoleReport,
20    pub selected: Option<&'a UnitId>,
21    /// Cleared when the report or the selection changes, so animation frames
22    /// elsewhere in the window do not redraw the graph.
23    pub cache: &'a canvas::Cache,
24}
25
26/// Layers by longest chain of functional dependencies: a unit sits below
27/// everything it requires or wants.
28pub fn layout(r: &ConsoleReport, bounds: Size) -> BTreeMap<UnitId, Point> {
29    let deps = |u: &UnitId| -> Vec<UnitId> {
30        r.edges
31            .iter()
32            .filter(|e| &e.from == u && matches!(e.kind, EdgeKind::Requires | EdgeKind::Wants))
33            .map(|e| e.to.clone())
34            .collect()
35    };
36    let mut depth: BTreeMap<UnitId, usize> = r.nodes.iter().map(|n| (n.unit.clone(), 0)).collect();
37    // Relax at most n times; cycles (diagnostic edges are excluded) stop growing.
38    for _ in 0..r.nodes.len() {
39        let mut changed = false;
40        for n in &r.nodes {
41            let d = deps(&n.unit).iter().filter_map(|t| depth.get(t).map(|d| d + 1)).max().unwrap_or(0);
42            let cur = depth.get_mut(&n.unit).expect("node");
43            if d > *cur && d <= r.nodes.len() {
44                *cur = d;
45                changed = true;
46            }
47        }
48        if !changed {
49            break;
50        }
51    }
52    let mut layers: BTreeMap<usize, Vec<UnitId>> = BTreeMap::new();
53    for (u, d) in &depth {
54        layers.entry(*d).or_default().push(u.clone());
55    }
56    // Each layer wraps into as many rows as the width needs; rows are evenly
57    // spaced, layers separated by an extra gap.
58    let per_row = (((bounds.width - GAP) / (NODE.width + GAP)).floor() as usize).max(1);
59    let mut out = BTreeMap::new();
60    let mut y = GAP;
61    for units in layers.values() {
62        for chunk in units.chunks(per_row) {
63            let slot = bounds.width / chunk.len() as f32;
64            for (j, u) in chunk.iter().enumerate() {
65                let x = slot * j as f32 + (slot - NODE.width).max(0.0) / 2.0;
66                out.insert(u.clone(), Point::new(x, y));
67            }
68            y += NODE.height + GAP;
69        }
70        y += GAP;
71    }
72    out
73}
74
75const GAP: f32 = 18.0;
76
77fn health_color(s: &UnitStatus) -> Color {
78    match s {
79        UnitStatus::Running | UnitStatus::Bound => Color::from_rgb8(0x2e, 0x9e, 0x4f),
80        UnitStatus::Degraded(_) | UnitStatus::Transitioning => Color::from_rgb8(0xe0, 0x9a, 0x1a),
81        UnitStatus::Failed(_) => Color::from_rgb8(0xc8, 0x32, 0x32),
82        UnitStatus::Overridden(_) => Color::from_rgb8(0x5a, 0x6f, 0xc0),
83        UnitStatus::Stopped | UnitStatus::Absent | UnitStatus::Detected => Color::from_rgb8(0x80, 0x80, 0x80),
84        UnitStatus::Unknown | UnitStatus::NotExpected => Color::from_rgb8(0xb0, 0xb0, 0xb0),
85    }
86}
87
88fn observed_color(o: &str) -> Color {
89    if o.starts_with("Active") || o.starts_with("Bound") {
90        Color::from_rgb8(0x2e, 0x9e, 0x4f)
91    } else if o.starts_with("Failed") {
92        Color::from_rgb8(0xc8, 0x32, 0x32)
93    } else if o.starts_with("Transitioning") {
94        Color::from_rgb8(0xe0, 0x9a, 0x1a)
95    } else {
96        Color::from_rgb8(0xb0, 0xb0, 0xb0)
97    }
98}
99
100fn desired_color(n: &GraphNode) -> Color {
101    if !n.expected {
102        Color::from_rgb8(0xb0, 0xb0, 0xb0)
103    } else if n.desired == "Stopped" || n.desired == "Absent" {
104        Color::from_rgb8(0x80, 0x80, 0x80)
105    } else {
106        Color::from_rgb8(0x2e, 0x9e, 0x4f)
107    }
108}
109
110impl canvas::Program<Message> for Graph<'_> {
111    type State = ();
112
113    fn update(
114        &self,
115        _state: &mut (),
116        event: &canvas::Event,
117        bounds: Rectangle,
118        cursor: mouse::Cursor,
119    ) -> Option<canvas::Action<Message>> {
120        let canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event else { return None };
121        let p = cursor.position_in(bounds)?;
122        let pos = layout(self.report, bounds.size());
123        let hit = pos.iter().find(|(_, at)| Rectangle::new(**at, NODE).contains(p))?;
124        Some(canvas::Action::publish(Message::Select(hit.0.clone())))
125    }
126
127    fn draw(
128        &self,
129        _state: &(),
130        renderer: &Renderer,
131        _theme: &Theme,
132        bounds: Rectangle,
133        _cursor: mouse::Cursor,
134    ) -> Vec<Geometry> {
135        vec![self.cache.draw(renderer, bounds.size(), |f| self.paint(f, bounds))]
136    }
137}
138
139impl Graph<'_> {
140    fn paint(&self, f: &mut Frame, bounds: Rectangle) {
141        let pos = layout(self.report, bounds.size());
142        let center = |u: &UnitId| pos.get(u).map(|p| Point::new(p.x + NODE.width / 2.0, p.y + NODE.height / 2.0));
143
144        for e in &self.report.edges {
145            let (Some(a), Some(b)) = (center(&e.from), center(&e.to)) else { continue };
146            let (segments, width, alpha): (&[f32], f32, f32) = match e.kind {
147                EdgeKind::Requires => (&[], 2.0, 0.8),
148                EdgeKind::Wants => (&[8.0, 5.0], 1.5, 0.7),
149                EdgeKind::Ordering => (&[2.0, 4.0], 1.0, 0.5),
150                EdgeKind::Diagnostic { confidence } => {
151                    (&[10.0, 4.0, 2.0, 4.0], 1.5, 0.25 + 0.5 * confidence.0 as f32 / 1000.0)
152                }
153            };
154            let stroke = Stroke {
155                line_dash: LineDash { segments, offset: 0 },
156                ..Stroke::default()
157                    .with_color(Color { a: alpha, ..Color::from_rgb8(0xb4, 0xb8, 0xbe) })
158                    .with_width(width)
159            };
160            f.stroke(&Path::line(a, b), stroke);
161        }
162
163        for n in &self.report.nodes {
164            let Some(at) = pos.get(&n.unit) else { continue };
165            let rect = Path::rounded_rectangle(*at, NODE, 6.0.into());
166            let fill = if n.stale { Color::from_rgb8(0x2e, 0x31, 0x35) } else { RAISED };
167            f.fill(&rect, fill);
168            if n.suppressed {
169                // Hatching: diagonal strokes clipped by eye to the box.
170                let mut x = 0.0;
171                while x < NODE.width + NODE.height {
172                    let a = Point::new(at.x + x.min(NODE.width), at.y + (x - NODE.width).max(0.0));
173                    let b = Point::new(at.x + (x - NODE.height).max(0.0), at.y + x.min(NODE.height));
174                    f.stroke(
175                        &Path::line(a, b),
176                        Stroke::default().with_color(Color::from_rgba8(0x7a, 0x8f, 0xe0, 0.35)).with_width(1.0),
177                    );
178                    x += 9.0;
179                }
180            }
181            let selected = self.selected == Some(&n.unit);
182            f.stroke(
183                &rect,
184                Stroke::default()
185                    .with_color(if selected { TEXT } else { Color::from_rgb8(0x4a, 0x4f, 0x56) })
186                    .with_width(if selected { 2.5 } else { 1.0 }),
187            );
188            f.fill_text(Text {
189                content: n.unit.name.clone(),
190                position: Point::new(at.x + 8.0, at.y + 6.0),
191                size: 14.0.into(),
192                color: TEXT,
193                ..Text::default()
194            });
195            for (i, c) in [desired_color(n), observed_color(&n.observed), health_color(&n.health)].iter().enumerate() {
196                let p = Point::new(at.x + 14.0 + 16.0 * i as f32, at.y + NODE.height - 12.0);
197                f.fill(&Path::circle(p, 5.0), *c);
198            }
199            if n.stale {
200                // A small clock: no fresh evidence.
201                let c = Point::new(at.x + NODE.width - 16.0, at.y + NODE.height - 14.0);
202                let s = Stroke::default().with_color(MUTED).with_width(1.2);
203                f.stroke(&Path::circle(c, 6.0), s);
204                f.stroke(&Path::line(c, Point::new(c.x, c.y - 4.0)), s);
205                f.stroke(&Path::line(c, Point::new(c.x + 3.0, c.y)), s);
206            }
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use planarian_ipc::{GraphEdge, StatusReport};
215    use planarian_schema::GenerationId;
216
217    fn node(name: &str) -> GraphNode {
218        GraphNode {
219            unit: UnitId::service(name),
220            desired: "Running".into(),
221            expected: true,
222            observed: "Active".into(),
223            health: UnitStatus::Running,
224            suppressed: false,
225            stale: false,
226        }
227    }
228
229    #[test]
230    fn dependencies_sit_above_their_dependents() {
231        let status: StatusReport = serde_json::from_value(serde_json::json!({
232            "generation": 1, "known_good": null, "live_tested": null, "accepted_baseline": null,
233            "boot_default": null, "baseline_note": null, "units": [], "incidents": [], "pending": [],
234            "in_flight": [], "interlock": [], "reconciliation": null, "notices": []
235        }))
236        .unwrap();
237        let r = ConsoleReport {
238            status,
239            nodes: vec![node("app"), node("db"), node("cache")],
240            edges: vec![
241                GraphEdge { from: UnitId::service("app"), to: UnitId::service("db"), kind: EdgeKind::Requires },
242                GraphEdge { from: UnitId::service("cache"), to: UnitId::service("app"), kind: EdgeKind::Ordering },
243            ],
244            timeline: vec![],
245            previous: None,
246            last_booted: Some(GenerationId(1)),
247            candidates: vec![],
248            last_change_reverted: false,
249            now: Default::default(),
250        };
251        let p = layout(&r, Size::new(800.0, 600.0));
252        assert!(p[&UnitId::service("db")].y < p[&UnitId::service("app")].y);
253        // Ordering is not a dependency: cache shares the top layer with db.
254        assert_eq!(p[&UnitId::service("cache")].y, p[&UnitId::service("db")].y);
255    }
256}