1use 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 pub cache: &'a canvas::Cache,
24}
25
26pub 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 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 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 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 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 assert_eq!(p[&UnitId::service("cache")].y, p[&UnitId::service("db")].y);
255 }
256}