//! 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;