//! What magicmirror knows, and the three tabs derived from it. //! //! The model holds one [`SourceState`] per configured source and nothing about //! what any of them mean. Every row on every tab is computed from the payloads //! alone, which is what keeps the shell from acquiring domain knowledge one //! convenience at a time. use chrono::{DateTime, TimeDelta, Utc}; use ops_status::{Action, Event, Node, Payload, Status}; use crate::config::Series; use crate::store::Reading; /// One source, as last heard from. pub(crate) struct SourceState { pub name: String, /// Whether this source's declared actions may be fired. Off by default; set /// from the config's per-source `allow_actions`. Kept on the state rather /// than threaded through key handling so the model stays self-contained and /// testable. pub allow_actions: bool, /// The last payload that parsed. Kept across a failed poll so the screen /// shows the last known state alongside the fact that it is now stale, /// rather than going blank. pub payload: Option, /// Why the last poll failed, if it did. pub error: Option, /// When a poll last succeeded. pub last_ok: Option>, /// Age past which this source's answer stops counting as current. pub stale_after: TimeDelta, } impl SourceState { pub(crate) fn new(name: impl Into, stale_after: TimeDelta) -> Self { SourceState { name: name.into(), allow_actions: false, payload: None, error: None, last_ok: None, stale_after, } } /// Let this source's declared actions be fired. Builder-style so tests and /// `main` set it without a wider constructor. pub(crate) fn with_actions(mut self, allow: bool) -> Self { self.allow_actions = allow; self } /// A declared action by key, from the current payload. `None` if the source /// has not answered or no longer offers it — the latter matters because a /// poll between opening a prompt and confirming can retract an action. pub(crate) fn action(&self, key: &str) -> Option<&Action> { self.payload.as_ref().and_then(|p| p.actions.get(key)) } /// How old the current payload is, if there is one. pub(crate) fn age(&self, now: DateTime) -> Option { self.payload.as_ref().map(|p| p.age(now)) } pub(crate) fn is_stale(&self, now: DateTime) -> bool { self.age(now).is_some_and(|age| age > self.stale_after) } /// This source's line on the live tab. /// /// Three things can be wrong and all three are visible here: /// /// - it cannot be reached at all (`unknown`) /// - it answers, but with something old (`degraded` at minimum, however /// green its contents) /// - it answers freshly and reports trouble (whatever it reports) /// /// The middle case is the one that is normally missed. A backup check that /// answers "ok" about a snapshot taken forty days ago is not ok, and every /// check that existed said it was. pub(crate) fn status(&self, now: DateTime) -> Status { let Some(payload) = &self.payload else { return Status::Unknown; }; if self.error.is_some() || self.is_stale(now) { return payload.worst_status().max(Status::Degraded); } payload.worst_status() } /// A short phrase for why this source reads the way it does. pub(crate) fn summary(&self, now: DateTime) -> String { if let Some(error) = &self.error { let last = match self.last_ok { Some(at) => crate::value::relative(at, now), None => "never".into(), }; return format!("unreachable ({error}); last ok {last}"); } let Some(payload) = &self.payload else { return "waiting for first poll".into(); }; if self.is_stale(now) { return format!( "stale: last answered {}", crate::value::duration(payload.age(now).num_seconds()) ); } let failing = payload .nodes .iter() .filter(|n| n.status >= Status::Degraded) .count(); match (failing, payload.nodes.len()) { (0, 1) => "1 node ok".into(), (0, total) => format!("{total} nodes ok"), (1, _) => "1 node needs attention".into(), (n, _) => format!("{n} nodes need attention"), } } /// Nodes in display order: each root followed by its children. /// /// Children are referenced by id rather than nested, so this is where the /// flat list becomes a tree. Depth stops at one: the contract allows deeper /// nesting but nothing emits it, and an unbounded recursion over /// producer-supplied ids is a denial-of-service waiting to happen. pub(crate) fn rows(&self) -> Vec> { let Some(payload) = &self.payload else { return Vec::new(); }; let mut rows = Vec::new(); for root in payload.roots() { rows.push(Row { node: root, depth: 0, }); for child_id in &root.children { if let Some(child) = payload.node(child_id) { rows.push(Row { node: child, depth: 1, }); } } } rows } /// Record a successful poll. /// /// The cursor is not this type's business any more: with the per-source /// tabs gone there is one cursor, it lives on [`Model`], and it is over a /// list this source is only part of. A poll that shrinks a payload is /// clamped there ([`Model::clamp_selection`]) rather than here. pub(crate) fn observe(&mut self, payload: Payload, at: DateTime) { self.payload = Some(payload); self.error = None; self.last_ok = Some(at); } /// Record a failed poll, keeping the last known payload. pub(crate) fn observe_error(&mut self, error: impl Into) { self.error = Some(error.into()); } } /// One of a source's nodes, with how deep in that source's own tree it sits. /// The live tab flattens these under their source ([`Model::live_rows`]). pub(crate) struct Row<'a> { pub node: &'a Node, pub depth: usize, } /// Which tab is showing. /// /// Three fixed tabs, not one per source. The per-source tabs this replaces were /// the situation the rollup existed to end -- N screens you still had to visit /// one at a time -- shipping beside the thing that replaced them. /// /// Adding a tab is a line in [`Tab::ALL`], a line in [`Tab::title`] and an arm /// in the renderer. Nothing indexes this by number, so nothing else has to /// learn how many there are. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Tab { /// Every source at once, worst first, each source's nodes under it. The tab /// that earns the product. Live, /// What every source has said lately, grouped by which one said it. Logs, /// Series a configured store has recorded. Infra `9d0e7098`. Store, } impl Tab { /// Left-to-right order, and the only place that knows how many there are. pub(crate) const ALL: [Tab; 3] = [Tab::Live, Tab::Logs, Tab::Store]; /// The tab bar, left to right. On [`Tab`] rather than on the model: the /// titles are fixed now, so nothing about them depends on what is /// configured. pub(crate) fn titles() -> Vec<&'static str> { Tab::ALL.iter().map(|tab| tab.title()).collect() } pub(crate) fn title(self) -> &'static str { match self { Tab::Live => "live", Tab::Logs => "logs", Tab::Store => "store", } } } /// One line in the live tab: a source, or a node belonging to one. /// /// The two are one list rather than two panes because there is one cursor. A /// source line is the rollup line it always was; the node lines under it are /// what the source's own tab used to hold, and are where an action is reachable /// from. pub(crate) enum LiveRow<'a> { Source { index: usize, }, Node { /// Which source this node came from, for resolving its actions. index: usize, node: &'a Node, /// 1 for a source's own node, 2 for a child of one. The source line is /// 0, so this is an indent level and not the contract's nesting depth. depth: usize, }, } impl LiveRow<'_> { /// The source this row belongs to, whichever kind it is. pub(crate) fn source_index(&self) -> usize { match self { LiveRow::Source { index } | LiveRow::Node { index, .. } => *index, } } pub(crate) fn node(&self) -> Option<&Node> { match self { LiveRow::Source { .. } => None, LiveRow::Node { node, .. } => Some(node), } } } /// One line in the logs tab: an event, and which source said it. pub(crate) struct LogRow<'a> { pub source: &'a str, pub event: &'a Event, } /// One configured observation store, as last read. pub(crate) struct StoreState { pub name: String, /// The series this store is configured to show, in the order the operator /// named them. This is where a number's meaning comes from; the store /// itself cannot say. pub series: Vec, /// The last read that succeeded, kept across a failed one so a producer /// whose file went away still shows what it last recorded. pub readings: Vec, /// Why the last read failed, if it did. pub error: Option, pub last_ok: Option>, } impl StoreState { pub(crate) fn new(name: impl Into, series: Vec) -> Self { StoreState { name: name.into(), series, readings: Vec::new(), error: None, last_ok: None, } } pub(crate) fn observe(&mut self, readings: Vec, at: DateTime) { self.readings = readings; self.error = None; self.last_ok = Some(at); } pub(crate) fn observe_error(&mut self, error: impl Into) { self.error = Some(error.into()); } } /// One line in the store tab. pub(crate) enum StoreRow<'a> { /// The store could not be read. Loud, and above whatever it last said, so /// old numbers are never mistaken for current ones. Unavailable { store: &'a str, reason: &'a str }, /// A configured series the store holds no observation of. Shown rather than /// omitted: a soak target that has never reported is exactly the thing you /// want to notice, and silence would hide it. Missing { store: &'a str, spec: &'a Series }, /// A configured series, one row per label set it was recorded under. Value { store: &'a str, spec: &'a Series, reading: &'a Reading, }, } /// A modal step between "I want to run this" and the request going out. /// /// Firing a declared action can move production, so the path to it is explicit /// and never a single keystroke: pick which action, then clear its guard. The /// guard's weight is set by the action itself — a `danger` action is confirmed /// by typing its key, a `confirm` one by a `y`, a plain one not at all. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Prompt { /// Choosing which of the selected node's actions to run. Pick { source: usize, keys: Vec, selected: usize, }, /// A `y`/`n` guard for a `confirm` action that is not `danger`. Confirm { source: usize, key: String }, /// The heaviest guard: type the action key to fire a `danger` action. Muscle /// memory cannot type `rollback-b`, which is the point. Type { source: usize, key: String, typed: String, }, } /// A confirmed request the run loop is to issue. The model records it and stops /// there; resolving the source's URL and token and making the HTTP call is the /// loop's job, which keeps every network effect out of the model. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct FireRequest { pub source: usize, pub key: String, } /// What a keypress asked the model to do once the prompt resolved. #[derive(Debug, PartialEq, Eq)] pub(crate) enum PromptStep { /// Still in a prompt (or none was open); nothing to dispatch. Idle, /// The prompt closed with a confirmed request to fire. Fire(FireRequest), /// The prompt closed without firing. Cancelled, } pub(crate) struct Model { pub sources: Vec, pub stores: Vec, pub tab: Tab, /// Cursor within the live tab, over [`Model::live_rows`]. pub selected: usize, /// How far the logs tab is scrolled, in rows. pub logs_scroll: usize, /// How far the store tab is scrolled, in rows. pub store_scroll: usize, /// Transient message shown in the footer. pub message: Option, /// The open modal, if any. pub prompt: Option, } impl Model { pub(crate) fn new(sources: Vec) -> Self { Model { sources, stores: Vec::new(), tab: Tab::Live, selected: 0, logs_scroll: 0, store_scroll: 0, message: None, prompt: None, } } /// The configured stores. Separate from [`Model::new`] because a store is /// optional and most configs have none, so the common construction should /// not have to say so. pub(crate) fn with_stores(mut self, stores: Vec) -> Self { self.stores = stores; self } // -- Actions ----------------------------------------------------------- /// Enter on a node in the live tab: open the action picker, or explain why /// not. /// /// This is where the per-source tabs' one irreplaceable job landed. A source /// line does nothing (a source declares no actions; its nodes do), and so /// does a node with none. A source with actions disabled says so rather than /// silently ignoring the key, because a panel that looks like it should be /// able to act and does not is worse than one that says it cannot. pub(crate) fn open_actions(&mut self, now: DateTime) { if self.tab != Tab::Live { return; } let rows = self.live_rows(now); let Some(row) = rows.get(self.selected) else { return; }; let index = row.source_index(); let Some(node) = row.node() else { return }; if node.actions.is_empty() { return; } let keys = node.actions.clone(); drop(rows); let Some(source) = self.sources.get(index) else { return; }; if !source.allow_actions { self.message = Some(format!( "{}: actions are read-only here (set allow_actions to enable)", source.name )); return; } self.prompt = Some(Prompt::Pick { source: index, keys, selected: 0, }); } /// Move the cursor inside an open picker. No-op for the other prompts. pub(crate) fn prompt_move(&mut self, delta: isize) { if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt { if keys.is_empty() { return; } let next = *selected as isize + delta; *selected = next.clamp(0, keys.len() as isize - 1) as usize; } } /// A digit inside a picker jumps straight to that action (1-based). pub(crate) fn prompt_digit(&mut self, n: usize) { if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt && (1..=keys.len()).contains(&n) { *selected = n - 1; } } /// A printable character while typing a `danger` action's key. pub(crate) fn prompt_push(&mut self, c: char) { if let Some(Prompt::Type { typed, .. }) = &mut self.prompt { typed.push(c); } } /// Backspace while typing. pub(crate) fn prompt_backspace(&mut self) { if let Some(Prompt::Type { typed, .. }) = &mut self.prompt { typed.pop(); } } /// Enter: advance the picker into a guard, or clear a `Type` guard. /// /// - On a picker, resolves the chosen action and either opens its guard /// (`Type` for danger, `Confirm` for confirm) or fires it outright. /// - On a `Type` guard, fires only when the typed text matches the key. /// - `Confirm` does not respond to Enter; it wants an explicit `y` /// ([`confirm_yes`]), so a stray Enter cannot promote through it. pub(crate) fn prompt_enter(&mut self) -> PromptStep { match self.prompt.take() { Some(Prompt::Pick { source, keys, selected, }) => { let Some(key) = keys.get(selected).cloned() else { return PromptStep::Cancelled; }; let Some(action) = self.sources.get(source).and_then(|s| s.action(&key)) else { self.message = Some(format!("{key}: no longer offered")); return PromptStep::Cancelled; }; if action.danger { self.prompt = Some(Prompt::Type { source, key, typed: String::new(), }); PromptStep::Idle } else if action.confirm { self.prompt = Some(Prompt::Confirm { source, key }); PromptStep::Idle } else { self.fire(source, key) } } Some(Prompt::Type { source, key, typed }) => { if typed == key { self.fire(source, key) } else { self.message = Some(format!("type '{key}' exactly to confirm")); self.prompt = Some(Prompt::Type { source, key, typed: String::new(), }); PromptStep::Idle } } other => { self.prompt = other; PromptStep::Idle } } } /// `y` on a `Confirm` guard fires; anywhere else it is nothing. pub(crate) fn confirm_yes(&mut self) -> PromptStep { if let Some(Prompt::Confirm { source, key }) = self.prompt.take() { self.fire(source, key) } else { PromptStep::Idle } } /// Close any open prompt without firing. pub(crate) fn cancel_prompt(&mut self) -> PromptStep { if self.prompt.take().is_some() { PromptStep::Cancelled } else { PromptStep::Idle } } /// Record a confirmed request and clear the prompt. Guards checked the /// action still existed, so this only assembles the request; the loop makes /// the call. fn fire(&mut self, source: usize, key: String) -> PromptStep { self.prompt = None; PromptStep::Fire(FireRequest { source, key }) } /// Source indices ordered worst-first, then by name. /// /// Worst-first is the whole argument for the rollup existing. Sorted any /// other way it is a list you still have to read all of, which is the /// situation it replaces. pub(crate) fn rollup_order(&self, now: DateTime) -> Vec { let mut order: Vec = (0..self.sources.len()).collect(); order.sort_by(|&a, &b| { let (sa, sb) = (self.sources[a].status(now), self.sources[b].status(now)); sb.cmp(&sa) .then_with(|| self.sources[a].name.cmp(&self.sources[b].name)) }); order } /// The worst status across every source: the one thing to look at first. pub(crate) fn worst(&self, now: DateTime) -> Status { self.sources .iter() .map(|s| s.status(now)) .max() .unwrap_or(Status::Unknown) } // -- Rows -------------------------------------------------------------- /// The live tab's lines: each source worst-first, its nodes under it. /// /// One flat list rather than a table over a tree, because the cursor moves /// through it and a cursor that has to know about collapsed subtrees is a /// file browser. Depth is carried per row and the renderer indents by it. pub(crate) fn live_rows(&self, now: DateTime) -> Vec> { let mut rows = Vec::new(); for index in self.rollup_order(now) { rows.push(LiveRow::Source { index }); for row in self.sources[index].rows() { rows.push(LiveRow::Node { index, node: row.node, depth: row.depth + 1, }); } } rows } /// The logs tab's lines: every event every source has reported, grouped by /// which source said it and newest first within each. /// /// Sources are in name order, not worst-first. A log whose sections /// rearrange themselves as statuses change is one you cannot read twice, and /// the live tab is where urgency belongs. pub(crate) fn log_rows(&self) -> Vec> { let mut order: Vec = (0..self.sources.len()).collect(); order.sort_by(|&a, &b| self.sources[a].name.cmp(&self.sources[b].name)); let mut rows = Vec::new(); for index in order { let source = &self.sources[index]; let Some(payload) = &source.payload else { continue; }; let mut events: Vec<&Event> = payload.events.iter().collect(); events.sort_by_key(|event| std::cmp::Reverse(event.at)); rows.extend(events.into_iter().map(|event| LogRow { source: source.name.as_str(), event, })); } rows } /// The store tab's lines: each configured store, then each series it was /// configured to show, in the order the operator named them. /// /// A series the store holds but nobody named is not here. That is the /// ruling, and the reason is that the data cannot say what it means: a /// number rendered without a label and a unit is a table browser with extra /// steps. Silence over noise. pub(crate) fn store_rows(&self) -> Vec> { let mut rows = Vec::new(); for store in &self.stores { if let Some(reason) = &store.error { rows.push(StoreRow::Unavailable { store: &store.name, reason, }); } for spec in &store.series { let mut any = false; for reading in store.readings.iter().filter(|r| r.series == spec.name) { any = true; rows.push(StoreRow::Value { store: &store.name, spec, reading, }); } if !any { rows.push(StoreRow::Missing { store: &store.name, spec, }); } } } rows } // -- Tabs and cursor ----------------------------------------------------- pub(crate) fn tab_index(&self) -> usize { Tab::ALL.iter().position(|t| *t == self.tab).unwrap_or(0) } /// Jump to a tab by position. Out of range is ignored rather than clamped: /// a mistyped digit should do nothing, not land somewhere near. pub(crate) fn select_tab(&mut self, index: usize) { if let Some(tab) = Tab::ALL.get(index) { self.tab = *tab; } } pub(crate) fn next_tab(&mut self) { self.select_tab((self.tab_index() + 1) % Tab::ALL.len()); } pub(crate) fn prev_tab(&mut self) { let count = Tab::ALL.len(); self.select_tab((self.tab_index() + count - 1) % count); } /// Move the cursor in whichever tab is showing. pub(crate) fn move_selection(&mut self, delta: isize, now: DateTime) { match self.tab { Tab::Live => { let len = self.live_rows(now).len(); self.selected = clamped(self.selected, delta, len); } Tab::Logs => { let len = self.log_rows().len(); self.logs_scroll = clamped(self.logs_scroll, delta, len); } Tab::Store => { let len = self.store_rows().len(); self.store_scroll = clamped(self.store_scroll, delta, len); } } } /// Pull the cursors back in bounds after a poll. /// /// A payload with fewer nodes than the last one shortens the live list under /// the cursor, and a source that dropped its events shortens the log. Called /// once per applied update rather than inside `observe`, because the lists /// span every source and no single one of them can know their length. pub(crate) fn clamp_selection(&mut self, now: DateTime) { let live = self.live_rows(now).len(); self.selected = self.selected.min(live.saturating_sub(1)); let logs = self.log_rows().len(); self.logs_scroll = self.logs_scroll.min(logs.saturating_sub(1)); let store = self.store_rows().len(); self.store_scroll = self.store_scroll.min(store.saturating_sub(1)); } } /// Move a cursor by `delta` within `len` rows, clamped at both ends. fn clamped(current: usize, delta: isize, len: usize) -> usize { if len == 0 { return 0; } let next = current as isize + delta; next.clamp(0, len as isize - 1) as usize } #[cfg(test)] mod tests { use super::*; use ops_status::{Action, Condition, Method, Node}; use std::collections::BTreeMap; fn now() -> DateTime { "2026-07-21T18:00:00Z".parse().unwrap() } fn node(id: &str, status: Status, children: Vec<&str>) -> Node { Node { id: id.into(), kind: "tier".into(), label: id.into(), status, fields: Vec::new(), conditions: Vec::new(), children: children.into_iter().map(Into::into).collect(), actions: Vec::new(), } } fn act(label: &str, confirm: bool, danger: bool) -> Action { Action { label: label.into(), method: Method::Post, url: "/x".into(), confirm, danger, body: None, } } /// A source on a single node that declares the given actions, with actions /// allowed, sitting on its own tab ready to prompt. fn actionable(node_actions: &[&str], declared: Vec<(&str, Action)>) -> Model { let mut n = node("tier:b", Status::Ok, vec![]); n.actions = node_actions.iter().map(ToString::to_string).collect(); let mut p = payload(now(), vec![n]); p.actions = declared .into_iter() .map(|(k, a)| (k.to_string(), a)) .collect::>(); let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); s.observe(p, now()); let mut m = Model::new(vec![s]); // Row 0 is the source line, row 1 its only node. Actions hang off the // node, so that is where the cursor has to be. m.selected = 1; m } fn event(at: DateTime, label: &str) -> Event { Event { at, label: label.into(), status: None, detail: None, node_id: None, } } fn payload(at: DateTime, nodes: Vec) -> Payload { let mut p = Payload::new("sando", at); p.nodes = nodes; p } fn source(name: &str, at: DateTime, nodes: Vec) -> SourceState { let mut s = SourceState::new(name, TimeDelta::seconds(60)); s.observe(payload(at, nodes), at); s } #[test] fn a_source_never_polled_is_unknown_not_ok() { let s = SourceState::new("sando", TimeDelta::seconds(60)); assert_eq!(s.status(now()), Status::Unknown); assert_eq!(s.summary(now()), "waiting for first poll"); } #[test] fn a_fresh_healthy_source_is_ok() { let s = source("sando", now(), vec![node("tier:b", Status::Ok, vec![])]); assert_eq!(s.status(now()), Status::Ok); assert_eq!(s.summary(now()), "1 node ok"); } #[test] fn a_stale_but_green_source_is_degraded() { // The forty-day-old backup that every check called healthy. let s = source( "pom", now() - TimeDelta::hours(4), vec![node("backup", Status::Ok, vec![])], ); assert_eq!(s.status(now()), Status::Degraded); assert!( s.summary(now()).starts_with("stale:"), "{}", s.summary(now()) ); } #[test] fn staleness_never_downgrades_a_worse_status() { let mut s = source( "sando", now() - TimeDelta::hours(4), vec![node("tier:b", Status::Failed, vec![])], ); assert_eq!(s.status(now()), Status::Failed); s.observe_error("connection refused"); assert_eq!(s.status(now()), Status::Failed); } #[test] fn an_unreachable_source_keeps_its_last_payload_and_says_when() { let mut s = source("bento", now(), vec![node("app:x", Status::Ok, vec![])]); s.observe_error("connection refused"); // Degraded, not Unknown: we still have a recent answer, we just could // not refresh it. assert_eq!(s.status(now()), Status::Degraded); assert!( s.payload.is_some(), "the last known state must not go blank" ); let summary = s.summary(now()); assert!(summary.contains("connection refused"), "{summary}"); assert!(summary.contains("last ok"), "{summary}"); } #[test] fn a_source_that_never_answered_and_then_failed_is_unknown() { let mut s = SourceState::new("bento", TimeDelta::seconds(60)); s.observe_error("connection refused"); assert_eq!(s.status(now()), Status::Unknown); assert!(s.summary(now()).contains("last ok never")); } #[test] fn rows_put_children_under_their_parent() { let s = source( "sando", now(), vec![ node("tier:b", Status::Ok, vec!["node:prod-1"]), node("node:prod-1", Status::Ok, vec![]), ], ); let rows = s.rows(); assert_eq!(rows.len(), 2); assert_eq!(rows[0].node.id, "tier:b"); assert_eq!(rows[0].depth, 0); assert_eq!(rows[1].node.id, "node:prod-1"); assert_eq!(rows[1].depth, 1); } #[test] fn a_dangling_child_reference_is_skipped_not_fatal() { let s = source( "sando", now(), vec![node("tier:b", Status::Ok, vec!["node:ghost"])], ); assert_eq!(s.rows().len(), 1); } #[test] fn the_rollup_puts_the_worst_source_first() { let m = Model::new(vec![ source("aaa", now(), vec![node("n", Status::Ok, vec![])]), source("bbb", now(), vec![node("n", Status::Failed, vec![])]), source("ccc", now(), vec![node("n", Status::Degraded, vec![])]), ]); let order = m.rollup_order(now()); let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect(); assert_eq!(names, vec!["bbb", "ccc", "aaa"]); assert_eq!(m.worst(now()), Status::Failed); } #[test] fn an_unreachable_source_outranks_a_merely_degraded_one() { let m = Model::new(vec![ source("degraded", now(), vec![node("n", Status::Degraded, vec![])]), SourceState::new("silent", TimeDelta::seconds(60)), ]); let order = m.rollup_order(now()); assert_eq!(m.sources[order[0]].name, "silent"); } #[test] fn equal_statuses_sort_by_name_so_the_order_does_not_jitter() { let m = Model::new(vec![ source("zebra", now(), vec![node("n", Status::Ok, vec![])]), source("alpha", now(), vec![node("n", Status::Ok, vec![])]), ]); let order = m.rollup_order(now()); let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect(); assert_eq!(names, vec!["alpha", "zebra"]); } #[test] fn the_tabs_are_fixed_and_wrap_in_both_directions() { // Two sources, three tabs: the count no longer follows the config, // which is the whole of what this restructure changed. let mut m = Model::new(vec![source("a", now(), vec![]), source("b", now(), vec![])]); assert_eq!(Tab::titles(), vec!["live", "logs", "store"]); assert_eq!(m.tab, Tab::Live); m.next_tab(); assert_eq!(m.tab, Tab::Logs); m.next_tab(); assert_eq!(m.tab, Tab::Store); m.next_tab(); assert_eq!(m.tab, Tab::Live); m.prev_tab(); assert_eq!(m.tab, Tab::Store); } #[test] fn a_tab_out_of_range_is_ignored_rather_than_clamped() { let mut m = Model::new(vec![source("a", now(), vec![])]); m.select_tab(1); assert_eq!(m.tab, Tab::Logs); m.select_tab(9); assert_eq!(m.tab, Tab::Logs, "a mistyped digit must not move the tab"); } #[test] fn the_live_rows_put_each_sources_nodes_under_it_worst_first() { let mut parent = node("tier:b", Status::Failed, vec!["node:prod-1"]); parent.children = vec!["node:prod-1".into()]; let m = Model::new(vec![ source("healthy", now(), vec![node("n", Status::Ok, vec![])]), source( "broken", now(), vec![parent, node("node:prod-1", Status::Ok, vec![])], ), ]); let rows = m.live_rows(now()); // The failing source leads, then its node, then its child, then the // healthy source and its node. assert_eq!(rows.len(), 5); assert!(matches!(rows[0], LiveRow::Source { index: 1 })); assert!(matches!(rows[1], LiveRow::Node { depth: 1, .. })); assert!(matches!(rows[2], LiveRow::Node { depth: 2, .. })); assert!(matches!(rows[3], LiveRow::Source { index: 0 })); assert_eq!(rows[1].source_index(), 1, "a node knows its own source"); assert!(rows[0].node().is_none(), "a source line is not a node"); } #[test] fn selection_cannot_run_off_either_end() { let mut m = Model::new(vec![source( "sando", now(), vec![node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![])], )]); m.move_selection(-5, now()); assert_eq!(m.selected, 0); m.move_selection(99, now()); // One source line plus two nodes. assert_eq!(m.selected, 2); } #[test] fn a_shrinking_payload_pulls_the_cursor_back_in_bounds() { // A poll that returns fewer nodes must not leave the cursor dangling. let mut m = Model::new(vec![source( "sando", now(), vec![ node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![]), node("c", Status::Ok, vec![]), ], )]); m.move_selection(3, now()); assert_eq!(m.selected, 3); m.sources[0].observe(payload(now(), vec![node("a", Status::Ok, vec![])]), now()); m.clamp_selection(now()); assert_eq!(m.selected, 1); assert!(m.live_rows(now()).get(m.selected).is_some()); } #[test] fn selection_survives_an_empty_payload() { let mut m = Model::new(vec![SourceState::new("sando", TimeDelta::seconds(60))]); m.sources[0].observe(payload(now(), vec![]), now()); m.move_selection(1, now()); // The source line is still a row; it just has nothing under it. assert_eq!(m.selected, 0); assert!(m.live_rows(now())[0].node().is_none()); } #[test] fn the_logs_group_by_source_by_name_and_run_newest_first() { let mut a = SourceState::new("zebra", TimeDelta::seconds(60)); let mut pz = payload(now(), vec![]); pz.events = vec![event(now(), "z-old"), event(now(), "z-new")]; pz.events[0].at = now() - TimeDelta::minutes(5); a.observe(pz, now()); let mut b = SourceState::new("alpha", TimeDelta::seconds(60)); let mut pa = payload(now(), vec![]); pa.events = vec![event(now(), "a-only")]; b.observe(pa, now()); let m = Model::new(vec![a, b]); let rows = m.log_rows(); let seen: Vec<(&str, &str)> = rows .iter() .map(|r| (r.source, r.event.label.as_str())) .collect(); assert_eq!( seen, vec![("alpha", "a-only"), ("zebra", "z-new"), ("zebra", "z-old"),], "sources in name order, events newest first within each" ); } fn spec(series: &str, label: &str) -> Series { Series { name: series.into(), label: label.into(), unit: Some("edges".into()), } } fn reading(series: &str, labels: &str, value: f64) -> Reading { Reading { series: series.into(), labels: labels.into(), value, at: now(), } } #[test] fn the_store_rows_follow_the_config_and_split_by_label_set() { let mut store = StoreState::new( "witchbroom", vec![ spec("soak.coverage_edges", "Coverage reached"), spec("cache.size_bytes", "Cache size"), ], ); store.observe( vec![ reading("soak.coverage_edges", r#"{"repo":"a"}"#, 100.0), reading("soak.coverage_edges", r#"{"repo":"b"}"#, 200.0), // In the store, never named in config: must not appear. reading("cache.hit_rate_pct", "{}", 90.0), ], now(), ); let m = Model::new(vec![]).with_stores(vec![store]); let rows = m.store_rows(); assert_eq!(rows.len(), 3, "two label sets plus the unrecorded series"); assert!(matches!( rows[0], StoreRow::Value { spec, .. } if spec.label == "Coverage reached" )); assert!(matches!(rows[1], StoreRow::Value { .. })); // A configured series the store has nothing for is shown, not skipped. assert!(matches!( rows[2], StoreRow::Missing { spec, .. } if spec.label == "Cache size" )); } #[test] fn a_series_the_config_never_named_is_not_a_row() { // The ruling's accepted cost. A fallback that rendered this "just in // case" is the table browser arriving by the back door. let mut store = StoreState::new("witchbroom", vec![spec("named", "Named")]); store.observe(vec![reading("unnamed", "{}", 1.0)], now()); let m = Model::new(vec![]).with_stores(vec![store]); let rows = m.store_rows(); assert_eq!(rows.len(), 1); assert!(matches!(rows[0], StoreRow::Missing { .. })); } #[test] fn an_unreadable_store_says_so_above_whatever_it_last_said() { let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]); store.observe(vec![reading("s", "{}", 41.0)], now()); store.observe_error("unable to open database file"); let m = Model::new(vec![]).with_stores(vec![store]); let rows = m.store_rows(); assert!( matches!(rows[0], StoreRow::Unavailable { reason, .. } if reason.contains("open")), "the failure leads, so old numbers are not read as current" ); assert!( matches!(rows[1], StoreRow::Value { reading, .. } if (reading.value - 41.0).abs() < f64::EPSILON), "the last known values are still there" ); } #[test] fn no_configured_store_is_no_rows_rather_than_an_empty_one() { assert!(Model::new(vec![]).store_rows().is_empty()); } #[test] fn the_store_cursor_cannot_run_off_either_end() { let mut store = StoreState::new("witchbroom", vec![spec("a", "A"), spec("b", "B")]); store.observe(vec![], now()); let mut m = Model::new(vec![]).with_stores(vec![store]); m.tab = Tab::Store; m.move_selection(99, now()); assert_eq!(m.store_scroll, 1); m.move_selection(-99, now()); assert_eq!(m.store_scroll, 0); } #[test] fn a_shrinking_store_pulls_its_cursor_back_in_bounds() { let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]); store.observe( vec![ reading("s", r#"{"repo":"a"}"#, 1.0), reading("s", r#"{"repo":"b"}"#, 2.0), reading("s", r#"{"repo":"c"}"#, 3.0), ], now(), ); let mut m = Model::new(vec![]).with_stores(vec![store]); m.tab = Tab::Store; m.move_selection(2, now()); assert_eq!(m.store_scroll, 2); m.stores[0].observe(vec![reading("s", r#"{"repo":"a"}"#, 1.0)], now()); m.clamp_selection(now()); assert_eq!(m.store_scroll, 0); } #[test] fn a_source_that_never_answered_contributes_no_log_rows() { let m = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]); assert!(m.log_rows().is_empty()); } #[test] fn a_disabled_source_refuses_to_open_the_picker_and_says_why() { let mut n = node("tier:b", Status::Ok, vec![]); n.actions = vec!["rollback-b".into()]; let mut p = payload(now(), vec![n]); p.actions .insert("rollback-b".into(), act("Roll back", true, true)); // allow_actions defaults off. let mut s = SourceState::new("sando", TimeDelta::seconds(60)); s.observe(p, now()); let mut m = Model::new(vec![s]); m.selected = 1; m.open_actions(now()); assert!( m.prompt.is_none(), "a read-only source must not open a prompt" ); assert!(m.message.as_deref().unwrap().contains("read-only")); } #[test] fn a_node_with_no_actions_does_nothing_on_enter() { let mut m = actionable(&[], vec![]); m.open_actions(now()); assert!(m.prompt.is_none()); assert!(m.message.is_none()); } #[test] fn a_source_line_never_opens_an_action_prompt() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.selected = 0; // the source line, not its node m.open_actions(now()); assert!( m.prompt.is_none(), "a source declares no actions; its nodes do" ); } #[test] fn the_other_tabs_never_open_an_action_prompt() { for tab in [Tab::Logs, Tab::Store] { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.tab = tab; m.open_actions(now()); assert!(m.prompt.is_none(), "{tab:?} must not fire actions"); } } #[test] fn a_plain_action_fires_straight_from_the_picker() { // No confirm, no danger: the picker is the whole ceremony. let mut m = actionable( &["recheck"], vec![("recheck", act("Recheck", false, false))], ); m.open_actions(now()); assert!(matches!(m.prompt, Some(Prompt::Pick { .. }))); let step = m.prompt_enter(); assert_eq!( step, PromptStep::Fire(FireRequest { source: 0, key: "recheck".into() }) ); assert!(m.prompt.is_none()); } #[test] fn a_confirm_action_needs_an_explicit_y_and_enter_will_not_do() { let mut m = actionable( &["promote-b"], vec![("promote-b", act("Promote", true, false))], ); m.open_actions(now()); assert_eq!(m.prompt_enter(), PromptStep::Idle); assert!( matches!(m.prompt, Some(Prompt::Confirm { .. })), "picker enter opens the y/n guard" ); // A stray Enter must not promote through a confirm guard. assert_eq!(m.prompt_enter(), PromptStep::Idle); assert!(matches!(m.prompt, Some(Prompt::Confirm { .. }))); // Only 'y' fires. let step = m.confirm_yes(); assert_eq!( step, PromptStep::Fire(FireRequest { source: 0, key: "promote-b".into() }) ); } #[test] fn a_danger_action_must_be_typed_out_to_fire() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.open_actions(now()); // Picker -> Type, not a y/n: danger outranks confirm. assert_eq!(m.prompt_enter(), PromptStep::Idle); assert!(matches!(m.prompt, Some(Prompt::Type { .. }))); // A wrong key does not fire; it resets the buffer with a nudge. for c in "rollback-a".chars() { m.prompt_push(c); } assert_eq!(m.prompt_enter(), PromptStep::Idle); assert!(m.message.as_deref().unwrap().contains("exactly")); if let Some(Prompt::Type { typed, .. }) = &m.prompt { assert!(typed.is_empty(), "a mismatch clears what was typed"); } else { panic!("still in Type after a mismatch"); } // The exact key fires. for c in "rollback-b".chars() { m.prompt_push(c); } m.prompt_backspace(); m.prompt_push('b'); let step = m.prompt_enter(); assert_eq!( step, PromptStep::Fire(FireRequest { source: 0, key: "rollback-b".into() }) ); assert!(m.prompt.is_none()); } #[test] fn esc_cancels_without_firing() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.open_actions(now()); assert_eq!(m.cancel_prompt(), PromptStep::Cancelled); assert!(m.prompt.is_none()); assert_eq!( m.cancel_prompt(), PromptStep::Idle, "nothing to cancel twice" ); } #[test] fn the_picker_moves_and_digits_jump_within_the_nodes_actions() { let mut m = actionable( &["promote-b", "rollback-b"], vec![ ("promote-b", act("Promote", true, false)), ("rollback-b", act("Roll back", true, true)), ], ); m.open_actions(now()); m.prompt_move(1); if let Some(Prompt::Pick { selected, .. }) = &m.prompt { assert_eq!(*selected, 1); } m.prompt_move(5); // clamps if let Some(Prompt::Pick { selected, .. }) = &m.prompt { assert_eq!(*selected, 1); } m.prompt_digit(1); if let Some(Prompt::Pick { selected, .. }) = &m.prompt { assert_eq!(*selected, 0); } m.prompt_digit(9); // out of range, ignored if let Some(Prompt::Pick { selected, .. }) = &m.prompt { assert_eq!(*selected, 0); } } #[test] fn an_action_retracted_between_pick_and_confirm_does_not_fire() { let mut m = actionable( &["rollback-b"], vec![("rollback-b", act("Roll back", true, true))], ); m.open_actions(now()); // A poll drops the action out from under the open picker. m.sources[0].observe( payload(now(), vec![node("tier:b", Status::Ok, vec![])]), now(), ); let step = m.prompt_enter(); assert_eq!(step, PromptStep::Cancelled); assert!(m.message.as_deref().unwrap().contains("no longer offered")); assert!(m.prompt.is_none()); } #[test] fn a_node_needing_attention_is_counted_once() { let mut n = node("tier:b", Status::Failed, vec![]); n.conditions.push(Condition { condition_type: "burn_in".into(), status: Status::Pending, since: None, detail: None, }); let s = source("sando", now(), vec![n, node("ok", Status::Ok, vec![])]); assert_eq!(s.summary(now()), "1 node needs attention"); } }