max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+565 insertions,
-257 deletions
| @@ -1056,7 +1056,7 @@ | |||
| 1056 | 1056 | ||
| 1057 | 1057 | [[package]] | |
| 1058 | 1058 | name = "magicmirror" | |
| 1059 | - | version = "0.1.1" | |
| 1059 | + | version = "0.2.0" | |
| 1060 | 1060 | dependencies = [ | |
| 1061 | 1061 | "anyhow", | |
| 1062 | 1062 | "chrono", | |
| @@ -2902,14 +2902,6 @@ | |||
| 2902 | 2902 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2903 | 2903 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 2904 | 2904 | ||
| 2905 | - | [[patch.unused]] | |
| 2906 | - | name = "synckit-client" | |
| 2907 | - | version = "0.8.1" | |
| 2908 | - | ||
| 2909 | - | [[patch.unused]] | |
| 2910 | - | name = "synckit-config" | |
| 2911 | - | version = "0.2.0" | |
| 2912 | - | ||
| 2913 | 2905 | [[patch.unused]] | |
| 2914 | 2906 | name = "quasi-axum" | |
| 2915 | 2907 | version = "0.56.0" | |
| @@ -2946,6 +2938,10 @@ | |||
| 2946 | 2938 | name = "quasi-webview" | |
| 2947 | 2939 | version = "0.56.0" | |
| 2948 | 2940 | ||
| 2941 | + | [[patch.unused]] | |
| 2942 | + | name = "docengine" | |
| 2943 | + | version = "0.7.0" | |
| 2944 | + | ||
| 2949 | 2945 | [[patch.unused]] | |
| 2950 | 2946 | name = "kberg" | |
| 2951 | 2947 | version = "0.1.0" | |
| @@ -2958,10 +2954,14 @@ | |||
| 2958 | 2954 | name = "tagtree" | |
| 2959 | 2955 | version = "0.4.1" | |
| 2960 | 2956 | ||
| 2961 | - | [[patch.unused]] | |
| 2962 | - | name = "docengine" | |
| 2963 | - | version = "0.7.0" | |
| 2964 | - | ||
| 2965 | 2957 | [[patch.unused]] | |
| 2966 | 2958 | name = "quasi-type" | |
| 2967 | 2959 | version = "0.1.0" | |
| 2960 | + | ||
| 2961 | + | [[patch.unused]] | |
| 2962 | + | name = "synckit-client" | |
| 2963 | + | version = "0.9.1" | |
| 2964 | + | ||
| 2965 | + | [[patch.unused]] | |
| 2966 | + | name = "synckit-config" | |
| 2967 | + | version = "0.2.0" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "magicmirror" | |
| 3 | - | version = "0.1.1" | |
| 3 | + | version = "0.2.0" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "MIT" | |
| 6 | 6 | description = "One terminal surface over every operator daemon that emits an ops-status payload. Tabs per source plus a worst-first rollup." |
| @@ -4,10 +4,11 @@ | |||
| 4 | 4 | //! Design + rationale: maintainer wiki. | |
| 5 | 5 | //! <!-- wiki: magicmirror-overview --> | |
| 6 | 6 | //! | |
| 7 | - | //! Tabs, one per hooked-in source, plus a rollup that shows every source at | |
| 8 | - | //! once, worst first. The rollup is the reason the product exists: without it | |
| 9 | - | //! this is N tabs you still have to visit one at a time, which is the situation | |
| 10 | - | //! it replaces. | |
| 7 | + | //! Three tabs: every source at once worst-first with its nodes under it, what | |
| 8 | + | //! every source has said lately grouped by which one said it, and the series a | |
| 9 | + | //! configured store has recorded. The first is the reason the product exists: | |
| 10 | + | //! without it this is N screens you still have to visit one at a time, which is | |
| 11 | + | //! the situation it replaces. | |
| 11 | 12 | //! | |
| 12 | 13 | //! magicmirror knows nothing about tiers, gates, apps, or targets. It renders | |
| 13 | 14 | //! the shared contract in `ops-status` and nothing else, which is what lets a | |
| @@ -139,8 +140,16 @@ | |||
| 139 | 140 | ||
| 140 | 141 | // Drain everything the pollers have produced without blocking, so a | |
| 141 | 142 | // burst of updates costs one redraw rather than one each. | |
| 143 | + | let mut applied = false; | |
| 142 | 144 | while let Ok(update) = updates.try_recv() { | |
| 143 | 145 | apply(&mut model, update); | |
| 146 | + | applied = true; | |
| 147 | + | } | |
| 148 | + | // A poll can shorten either list under the cursor, and the lists span | |
| 149 | + | // every source, so the clamp happens once here rather than inside each | |
| 150 | + | // source's own update. | |
| 151 | + | if applied { | |
| 152 | + | model.clamp_selection(now); | |
| 144 | 153 | } | |
| 145 | 154 | // Fired-action outcomes land in the footer. | |
| 146 | 155 | while let Ok(outcome) = action_rx.try_recv() { | |
| @@ -260,12 +269,12 @@ | |||
| 260 | 269 | KeyCode::BackTab | KeyCode::Left => model.prev_tab(), | |
| 261 | 270 | KeyCode::Down | KeyCode::Char('j') => model.move_selection(1, now), | |
| 262 | 271 | KeyCode::Up | KeyCode::Char('k') => model.move_selection(-1, now), | |
| 263 | - | KeyCode::Enter => match model.tab { | |
| 264 | - | model::Tab::Rollup => model.open_selected(now), | |
| 265 | - | model::Tab::Source(_) => model.open_actions(), | |
| 266 | - | }, | |
| 267 | - | KeyCode::Char(c) if c.is_ascii_digit() => { | |
| 268 | - | model.select_tab(c.to_digit(10).unwrap_or(0) as usize); | |
| 272 | + | KeyCode::Enter => model.open_actions(now), | |
| 273 | + | // 1-based, because the tabs are now labelled 1-3 in the footer and a | |
| 274 | + | // fixed set of three is something you point at rather than index from | |
| 275 | + | // zero. '0' falls through and does nothing. | |
| 276 | + | KeyCode::Char(c @ '1'..='9') => { | |
| 277 | + | model.select_tab(c.to_digit(10).unwrap_or(1) as usize - 1); | |
| 269 | 278 | } | |
| 270 | 279 | _ => {} | |
| 271 | 280 | } | |
| @@ -364,26 +373,31 @@ | |||
| 364 | 373 | ||
| 365 | 374 | #[test] | |
| 366 | 375 | fn number_keys_jump_straight_to_a_tab() { | |
| 376 | + | // Two sources, three tabs: the digits address the tabs, not the config. | |
| 367 | 377 | let mut model = model_with(&["sando", "bento"]); | |
| 368 | 378 | handle_key(&mut model, key(KeyCode::Char('2'))); | |
| 369 | - | assert_eq!(model.tab, model::Tab::Source(1)); | |
| 370 | - | handle_key(&mut model, key(KeyCode::Char('0'))); | |
| 371 | - | assert_eq!(model.tab, model::Tab::Rollup); | |
| 379 | + | assert_eq!(model.tab, model::Tab::Logs); | |
| 380 | + | handle_key(&mut model, key(KeyCode::Char('3'))); | |
| 381 | + | assert_eq!(model.tab, model::Tab::Store); | |
| 382 | + | handle_key(&mut model, key(KeyCode::Char('1'))); | |
| 383 | + | assert_eq!(model.tab, model::Tab::Live); | |
| 372 | 384 | // Out of range is ignored rather than panicking. | |
| 373 | 385 | handle_key(&mut model, key(KeyCode::Char('9'))); | |
| 374 | - | assert_eq!(model.tab, model::Tab::Rollup); | |
| 386 | + | assert_eq!(model.tab, model::Tab::Live); | |
| 387 | + | handle_key(&mut model, key(KeyCode::Char('0'))); | |
| 388 | + | assert_eq!(model.tab, model::Tab::Live); | |
| 375 | 389 | } | |
| 376 | 390 | ||
| 377 | 391 | #[test] | |
| 378 | 392 | fn arrows_and_vim_keys_both_move() { | |
| 379 | 393 | let mut model = model_with(&["sando", "bento"]); | |
| 380 | 394 | handle_key(&mut model, key(KeyCode::Tab)); | |
| 381 | - | assert_eq!(model.tab, model::Tab::Source(0)); | |
| 395 | + | assert_eq!(model.tab, model::Tab::Logs); | |
| 382 | 396 | handle_key(&mut model, key(KeyCode::BackTab)); | |
| 383 | - | assert_eq!(model.tab, model::Tab::Rollup); | |
| 397 | + | assert_eq!(model.tab, model::Tab::Live); | |
| 384 | 398 | handle_key(&mut model, key(KeyCode::Char('j'))); | |
| 385 | 399 | handle_key(&mut model, key(KeyCode::Char('k'))); | |
| 386 | - | assert_eq!(model.rollup_selected, 0); | |
| 400 | + | assert_eq!(model.selected, 0); | |
| 387 | 401 | } | |
| 388 | 402 | ||
| 389 | 403 | #[test] | |
| @@ -455,7 +469,9 @@ | |||
| 455 | 469 | let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); | |
| 456 | 470 | s.observe(p, Utc::now()); | |
| 457 | 471 | let mut m = Model::new(vec![s]); | |
| 458 | - | m.select_tab(1); | |
| 472 | + | // Row 0 is the source line, row 1 its only node, which is where the | |
| 473 | + | // action hangs. | |
| 474 | + | m.selected = 1; | |
| 459 | 475 | m | |
| 460 | 476 | } | |
| 461 | 477 |
| @@ -1,12 +1,12 @@ | |||
| 1 | - | //! What magicmirror knows, and the rollup derived from it. | |
| 1 | + | //! What magicmirror knows, and the three tabs derived from it. | |
| 2 | 2 | //! | |
| 3 | 3 | //! The model holds one [`SourceState`] per configured source and nothing about | |
| 4 | - | //! what any of them mean. Everything the rollup shows is computed from the | |
| 5 | - | //! payloads alone, which is what keeps the shell from acquiring domain | |
| 6 | - | //! knowledge one convenience at a time. | |
| 4 | + | //! what any of them mean. Every row on every tab is computed from the payloads | |
| 5 | + | //! alone, which is what keeps the shell from acquiring domain knowledge one | |
| 6 | + | //! convenience at a time. | |
| 7 | 7 | ||
| 8 | 8 | use chrono::{DateTime, TimeDelta, Utc}; | |
| 9 | - | use ops_status::{Action, Node, Payload, Status}; | |
| 9 | + | use ops_status::{Action, Event, Node, Payload, Status}; | |
| 10 | 10 | ||
| 11 | 11 | /// One source, as last heard from. | |
| 12 | 12 | pub(crate) struct SourceState { | |
| @@ -26,8 +26,6 @@ | |||
| 26 | 26 | pub last_ok: Option<DateTime<Utc>>, | |
| 27 | 27 | /// Age past which this source's answer stops counting as current. | |
| 28 | 28 | pub stale_after: TimeDelta, | |
| 29 | - | /// Row selection within this source's tab. | |
| 30 | - | pub selected: usize, | |
| 31 | 29 | } | |
| 32 | 30 | ||
| 33 | 31 | impl SourceState { | |
| @@ -39,7 +37,6 @@ | |||
| 39 | 37 | error: None, | |
| 40 | 38 | last_ok: None, | |
| 41 | 39 | stale_after, | |
| 42 | - | selected: 0, | |
| 43 | 40 | } | |
| 44 | 41 | } | |
| 45 | 42 | ||
| @@ -66,7 +63,7 @@ | |||
| 66 | 63 | self.age(now).is_some_and(|age| age > self.stale_after) | |
| 67 | 64 | } | |
| 68 | 65 | ||
| 69 | - | /// This source's line in the rollup. | |
| 66 | + | /// This source's line on the live tab. | |
| 70 | 67 | /// | |
| 71 | 68 | /// Three things can be wrong and all three are visible here: | |
| 72 | 69 | /// | |
| @@ -148,34 +145,16 @@ | |||
| 148 | 145 | rows | |
| 149 | 146 | } | |
| 150 | 147 | ||
| 151 | - | /// The node the cursor is on. | |
| 152 | - | pub(crate) fn selected_node(&self) -> Option<&Node> { | |
| 153 | - | let rows = self.rows(); | |
| 154 | - | rows.get(self.selected.min(rows.len().saturating_sub(1))) | |
| 155 | - | .map(|r| r.node) | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | pub(crate) fn move_selection(&mut self, delta: isize) { | |
| 159 | - | let len = self.rows().len(); | |
| 160 | - | if len == 0 { | |
| 161 | - | self.selected = 0; | |
| 162 | - | return; | |
| 163 | - | } | |
| 164 | - | let next = self.selected as isize + delta; | |
| 165 | - | self.selected = next.clamp(0, len as isize - 1) as usize; | |
| 166 | - | } | |
| 167 | - | ||
| 168 | 148 | /// Record a successful poll. | |
| 149 | + | /// | |
| 150 | + | /// The cursor is not this type's business any more: with the per-source | |
| 151 | + | /// tabs gone there is one cursor, it lives on [`Model`], and it is over a | |
| 152 | + | /// list this source is only part of. A poll that shrinks a payload is | |
| 153 | + | /// clamped there ([`Model::clamp_selection`]) rather than here. | |
| 169 | 154 | pub(crate) fn observe(&mut self, payload: Payload, at: DateTime<Utc>) { | |
| 170 | 155 | self.payload = Some(payload); | |
| 171 | 156 | self.error = None; | |
| 172 | 157 | self.last_ok = Some(at); | |
| 173 | - | // A payload with fewer nodes than before must not leave the cursor | |
| 174 | - | // pointing past the end. | |
| 175 | - | let len = self.rows().len(); | |
| 176 | - | if self.selected >= len { | |
| 177 | - | self.selected = len.saturating_sub(1); | |
| 178 | - | } | |
| 179 | 158 | } | |
| 180 | 159 | ||
| 181 | 160 | /// Record a failed poll, keeping the last known payload. | |
| @@ -184,18 +163,93 @@ | |||
| 184 | 163 | } | |
| 185 | 164 | } | |
| 186 | 165 | ||
| 187 | - | /// One line in a source tab. | |
| 166 | + | /// One of a source's nodes, with how deep in that source's own tree it sits. | |
| 167 | + | /// The live tab flattens these under their source ([`Model::live_rows`]). | |
| 188 | 168 | pub(crate) struct Row<'a> { | |
| 189 | 169 | pub node: &'a Node, | |
| 190 | 170 | pub depth: usize, | |
| 191 | 171 | } | |
| 192 | 172 | ||
| 193 | 173 | /// Which tab is showing. | |
| 174 | + | /// | |
| 175 | + | /// Three fixed tabs, not one per source. The per-source tabs this replaces were | |
| 176 | + | /// the situation the rollup existed to end -- N screens you still had to visit | |
| 177 | + | /// one at a time -- shipping beside the thing that replaced them. | |
| 178 | + | /// | |
| 179 | + | /// Adding a tab is a line in [`Tab::ALL`], a line in [`Tab::title`] and an arm | |
| 180 | + | /// in the renderer. Nothing indexes this by number, so nothing else has to | |
| 181 | + | /// learn how many there are. | |
| 194 | 182 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 195 | 183 | pub(crate) enum Tab { | |
| 196 | - | /// Every source at once, worst first. The tab that earns the product. | |
| 197 | - | Rollup, | |
| 198 | - | Source(usize), | |
| 184 | + | /// Every source at once, worst first, each source's nodes under it. The tab | |
| 185 | + | /// that earns the product. | |
| 186 | + | Live, | |
| 187 | + | /// What every source has said lately, grouped by which one said it. | |
| 188 | + | Logs, | |
| 189 | + | /// Series a configured store has recorded. Infra `9d0e7098`. | |
| 190 | + | Store, | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | impl Tab { | |
| 194 | + | /// Left-to-right order, and the only place that knows how many there are. | |
| 195 | + | pub(crate) const ALL: [Tab; 3] = [Tab::Live, Tab::Logs, Tab::Store]; | |
| 196 | + | ||
| 197 | + | /// The tab bar, left to right. On [`Tab`] rather than on the model: the | |
| 198 | + | /// titles are fixed now, so nothing about them depends on what is | |
| 199 | + | /// configured. | |
| 200 | + | pub(crate) fn titles() -> Vec<&'static str> { | |
| 201 | + | Tab::ALL.iter().map(|tab| tab.title()).collect() | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | pub(crate) fn title(self) -> &'static str { | |
| 205 | + | match self { | |
| 206 | + | Tab::Live => "live", | |
| 207 | + | Tab::Logs => "logs", | |
| 208 | + | Tab::Store => "store", | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// One line in the live tab: a source, or a node belonging to one. | |
| 214 | + | /// | |
| 215 | + | /// The two are one list rather than two panes because there is one cursor. A | |
| 216 | + | /// source line is the rollup line it always was; the node lines under it are | |
| 217 | + | /// what the source's own tab used to hold, and are where an action is reachable | |
| 218 | + | /// from. | |
| 219 | + | pub(crate) enum LiveRow<'a> { | |
| 220 | + | Source { | |
| 221 | + | index: usize, | |
| 222 | + | }, | |
| 223 | + | Node { | |
| 224 | + | /// Which source this node came from, for resolving its actions. | |
| 225 | + | index: usize, | |
| 226 | + | node: &'a Node, | |
| 227 | + | /// 1 for a source's own node, 2 for a child of one. The source line is | |
| 228 | + | /// 0, so this is an indent level and not the contract's nesting depth. | |
| 229 | + | depth: usize, | |
| 230 | + | }, | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | impl LiveRow<'_> { | |
| 234 | + | /// The source this row belongs to, whichever kind it is. | |
| 235 | + | pub(crate) fn source_index(&self) -> usize { | |
| 236 | + | match self { | |
| 237 | + | LiveRow::Source { index } | LiveRow::Node { index, .. } => *index, | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | pub(crate) fn node(&self) -> Option<&Node> { | |
| 242 | + | match self { | |
| 243 | + | LiveRow::Source { .. } => None, | |
| 244 | + | LiveRow::Node { node, .. } => Some(node), | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | /// One line in the logs tab: an event, and which source said it. | |
| 250 | + | pub(crate) struct LogRow<'a> { | |
| 251 | + | pub source: &'a str, | |
| 252 | + | pub event: &'a Event, | |
| 199 | 253 | } | |
| 200 | 254 | ||
| 201 | 255 | /// A modal step between "I want to run this" and the request going out. | |
| @@ -246,8 +300,10 @@ | |||
| 246 | 300 | pub(crate) struct Model { | |
| 247 | 301 | pub sources: Vec<SourceState>, | |
| 248 | 302 | pub tab: Tab, | |
| 249 | - | /// Cursor within the rollup tab. | |
| 250 | - | pub rollup_selected: usize, | |
| 303 | + | /// Cursor within the live tab, over [`Model::live_rows`]. | |
| 304 | + | pub selected: usize, | |
| 305 | + | /// How far the logs tab is scrolled, in rows. | |
| 306 | + | pub logs_scroll: usize, | |
| 251 | 307 | /// Transient message shown in the footer. | |
| 252 | 308 | pub message: Option<String>, | |
| 253 | 309 | /// The open modal, if any. | |
| @@ -258,8 +314,9 @@ | |||
| 258 | 314 | pub(crate) fn new(sources: Vec<SourceState>) -> Self { | |
| 259 | 315 | Model { | |
| 260 | 316 | sources, | |
| 261 | - | tab: Tab::Rollup, | |
| 262 | - | rollup_selected: 0, | |
| 317 | + | tab: Tab::Live, | |
| 318 | + | selected: 0, | |
| 319 | + | logs_scroll: 0, | |
| 263 | 320 | message: None, | |
| 264 | 321 | prompt: None, | |
| 265 | 322 | } | |
| @@ -267,23 +324,32 @@ | |||
| 267 | 324 | ||
| 268 | 325 | // -- Actions ----------------------------------------------------------- | |
| 269 | 326 | ||
| 270 | - | /// Enter on a source node: open the action picker, or explain why not. | |
| 327 | + | /// Enter on a node in the live tab: open the action picker, or explain why | |
| 328 | + | /// not. | |
| 271 | 329 | /// | |
| 272 | - | /// A node with no actions does nothing. A source with actions disabled says | |
| 273 | - | /// so rather than silently ignoring the key, because a panel that looks like | |
| 274 | - | /// it should be able to act and does not is worse than one that says it | |
| 275 | - | /// cannot. | |
| 276 | - | pub(crate) fn open_actions(&mut self) { | |
| 277 | - | let Tab::Source(i) = self.tab else { return }; | |
| 278 | - | let Some(source) = self.sources.get(i) else { | |
| 279 | - | return; | |
| 280 | - | }; | |
| 281 | - | let Some(node) = source.selected_node() else { | |
| 330 | + | /// This is where the per-source tabs' one irreplaceable job landed. A source | |
| 331 | + | /// line does nothing (a source declares no actions; its nodes do), and so | |
| 332 | + | /// does a node with none. A source with actions disabled says so rather than | |
| 333 | + | /// silently ignoring the key, because a panel that looks like it should be | |
| 334 | + | /// able to act and does not is worse than one that says it cannot. | |
| 335 | + | pub(crate) fn open_actions(&mut self, now: DateTime<Utc>) { | |
| 336 | + | if self.tab != Tab::Live { | |
| 337 | + | return; | |
| 338 | + | } | |
| 339 | + | let rows = self.live_rows(now); | |
| 340 | + | let Some(row) = rows.get(self.selected) else { | |
| 282 | 341 | return; | |
| 283 | 342 | }; | |
| 343 | + | let index = row.source_index(); | |
| 344 | + | let Some(node) = row.node() else { return }; | |
| 284 | 345 | if node.actions.is_empty() { | |
| 285 | 346 | return; | |
| 286 | 347 | } | |
| 348 | + | let keys = node.actions.clone(); | |
| 349 | + | drop(rows); | |
| 350 | + | let Some(source) = self.sources.get(index) else { | |
| 351 | + | return; | |
| 352 | + | }; | |
| 287 | 353 | if !source.allow_actions { | |
| 288 | 354 | self.message = Some(format!( | |
| 289 | 355 | "{}: actions are read-only here (set allow_actions to enable)", | |
| @@ -292,8 +358,8 @@ | |||
| 292 | 358 | return; | |
| 293 | 359 | } | |
| 294 | 360 | self.prompt = Some(Prompt::Pick { | |
| 295 | - | source: i, | |
| 296 | - | keys: node.actions.clone(), | |
| 361 | + | source: index, | |
| 362 | + | keys, | |
| 297 | 363 | selected: 0, | |
| 298 | 364 | }); | |
| 299 | 365 | } | |
| @@ -437,68 +503,115 @@ | |||
| 437 | 503 | .unwrap_or(Status::Unknown) | |
| 438 | 504 | } | |
| 439 | 505 | ||
| 440 | - | pub(crate) fn tab_titles(&self) -> Vec<String> { | |
| 441 | - | let mut titles = vec!["rollup".to_string()]; | |
| 442 | - | titles.extend(self.sources.iter().map(|s| s.name.clone())); | |
| 443 | - | titles | |
| 506 | + | // -- Rows -------------------------------------------------------------- | |
| 507 | + | ||
| 508 | + | /// The live tab's lines: each source worst-first, its nodes under it. | |
| 509 | + | /// | |
| 510 | + | /// One flat list rather than a table over a tree, because the cursor moves | |
| 511 | + | /// through it and a cursor that has to know about collapsed subtrees is a | |
| 512 | + | /// file browser. Depth is carried per row and the renderer indents by it. | |
| 513 | + | pub(crate) fn live_rows(&self, now: DateTime<Utc>) -> Vec<LiveRow<'_>> { | |
| 514 | + | let mut rows = Vec::new(); | |
| 515 | + | for index in self.rollup_order(now) { | |
| 516 | + | rows.push(LiveRow::Source { index }); | |
| 517 | + | for row in self.sources[index].rows() { | |
| 518 | + | rows.push(LiveRow::Node { | |
| 519 | + | index, | |
| 520 | + | node: row.node, | |
| 521 | + | depth: row.depth + 1, | |
| 522 | + | }); | |
| 523 | + | } | |
| 524 | + | } | |
| 525 | + | rows | |
| 444 | 526 | } | |
| 445 | 527 | ||
| 528 | + | /// The logs tab's lines: every event every source has reported, grouped by | |
| 529 | + | /// which source said it and newest first within each. | |
| 530 | + | /// | |
| 531 | + | /// Sources are in name order, not worst-first. A log whose sections | |
| 532 | + | /// rearrange themselves as statuses change is one you cannot read twice, and | |
| 533 | + | /// the live tab is where urgency belongs. | |
| 534 | + | pub(crate) fn log_rows(&self) -> Vec<LogRow<'_>> { | |
| 535 | + | let mut order: Vec<usize> = (0..self.sources.len()).collect(); | |
| 536 | + | order.sort_by(|&a, &b| self.sources[a].name.cmp(&self.sources[b].name)); | |
| 537 | + | ||
| 538 | + | let mut rows = Vec::new(); | |
| 539 | + | for index in order { | |
| 540 | + | let source = &self.sources[index]; | |
| 541 | + | let Some(payload) = &source.payload else { | |
| 542 | + | continue; | |
| 543 | + | }; | |
| 544 | + | let mut events: Vec<&Event> = payload.events.iter().collect(); | |
| 545 | + | events.sort_by_key(|event| std::cmp::Reverse(event.at)); | |
| 546 | + | rows.extend(events.into_iter().map(|event| LogRow { | |
| 547 | + | source: source.name.as_str(), | |
| 548 | + | event, | |
| 549 | + | })); | |
| 550 | + | } | |
| 551 | + | rows | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | // -- Tabs and cursor ----------------------------------------------------- | |
| 555 | + | ||
| 446 | 556 | pub(crate) fn tab_index(&self) -> usize { | |
| 447 | - | match self.tab { | |
| 448 | - | Tab::Rollup => 0, | |
| 449 | - | Tab::Source(i) => i + 1, | |
| 557 | + | Tab::ALL.iter().position(|t| *t == self.tab).unwrap_or(0) | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | /// Jump to a tab by position. Out of range is ignored rather than clamped: | |
| 561 | + | /// a mistyped digit should do nothing, not land somewhere near. | |
| 562 | + | pub(crate) fn select_tab(&mut self, index: usize) { | |
| 563 | + | if let Some(tab) = Tab::ALL.get(index) { | |
| 564 | + | self.tab = *tab; | |
| 450 | 565 | } | |
| 451 | 566 | } | |
| 452 | 567 | ||
| 453 | - | pub(crate) fn select_tab(&mut self, index: usize) { | |
| 454 | - | self.tab = match index { | |
| 455 | - | 0 => Tab::Rollup, | |
| 456 | - | n if n <= self.sources.len() => Tab::Source(n - 1), | |
| 457 | - | _ => self.tab, | |
| 458 | - | }; | |
| 459 | - | } | |
| 460 | - | ||
| 461 | 568 | pub(crate) fn next_tab(&mut self) { | |
| 462 | - | let next = (self.tab_index() + 1) % (self.sources.len() + 1); | |
| 463 | - | self.select_tab(next); | |
| 569 | + | self.select_tab((self.tab_index() + 1) % Tab::ALL.len()); | |
| 464 | 570 | } | |
| 465 | 571 | ||
| 466 | 572 | pub(crate) fn prev_tab(&mut self) { | |
| 467 | - | let count = self.sources.len() + 1; | |
| 468 | - | let next = (self.tab_index() + count - 1) % count; | |
| 469 | - | self.select_tab(next); | |
| 573 | + | let count = Tab::ALL.len(); | |
| 574 | + | self.select_tab((self.tab_index() + count - 1) % count); | |
| 470 | 575 | } | |
| 471 | 576 | ||
| 472 | 577 | /// Move the cursor in whichever tab is showing. | |
| 473 | 578 | pub(crate) fn move_selection(&mut self, delta: isize, now: DateTime<Utc>) { | |
| 474 | 579 | match self.tab { | |
| 475 | - | Tab::Rollup => { | |
| 476 | - | let len = self.rollup_order(now).len(); | |
| 477 | - | if len == 0 { | |
| 478 | - | return; | |
| 479 | - | } | |
| 480 | - | let next = self.rollup_selected as isize + delta; | |
| 481 | - | self.rollup_selected = next.clamp(0, len as isize - 1) as usize; | |
| 580 | + | Tab::Live => { | |
| 581 | + | let len = self.live_rows(now).len(); | |
| 582 | + | self.selected = clamped(self.selected, delta, len); | |
| 482 | 583 | } | |
| 483 | - | Tab::Source(i) => { | |
| 484 | - | if let Some(source) = self.sources.get_mut(i) { | |
| 485 | - | source.move_selection(delta); | |
| 486 | - | } | |
| 584 | + | Tab::Logs => { | |
| 585 | + | let len = self.log_rows().len(); | |
| 586 | + | self.logs_scroll = clamped(self.logs_scroll, delta, len); | |
| 487 | 587 | } | |
| 588 | + | Tab::Store => {} | |
| 488 | 589 | } | |
| 489 | 590 | } | |
| 490 | 591 | ||
| 491 | - | /// Enter on a rollup row opens that source's tab. | |
| 492 | - | pub(crate) fn open_selected(&mut self, now: DateTime<Utc>) { | |
| 493 | - | if self.tab == Tab::Rollup { | |
| 494 | - | let order = self.rollup_order(now); | |
| 495 | - | if let Some(&index) = order.get(self.rollup_selected) { | |
| 496 | - | self.tab = Tab::Source(index); | |
| 497 | - | } | |
| 498 | - | } | |
| 592 | + | /// Pull the cursors back in bounds after a poll. | |
| 593 | + | /// | |
| 594 | + | /// A payload with fewer nodes than the last one shortens the live list under | |
| 595 | + | /// the cursor, and a source that dropped its events shortens the log. Called | |
| 596 | + | /// once per applied update rather than inside `observe`, because the lists | |
| 597 | + | /// span every source and no single one of them can know their length. | |
| 598 | + | pub(crate) fn clamp_selection(&mut self, now: DateTime<Utc>) { | |
| 599 | + | let live = self.live_rows(now).len(); | |
| 600 | + | self.selected = self.selected.min(live.saturating_sub(1)); | |
| 601 | + | let logs = self.log_rows().len(); | |
| 602 | + | self.logs_scroll = self.logs_scroll.min(logs.saturating_sub(1)); | |
| 499 | 603 | } | |
| 500 | 604 | } | |
| 501 | 605 | ||
| 606 | + | /// Move a cursor by `delta` within `len` rows, clamped at both ends. | |
| 607 | + | fn clamped(current: usize, delta: isize, len: usize) -> usize { | |
| 608 | + | if len == 0 { | |
| 609 | + | return 0; | |
| 610 | + | } | |
| 611 | + | let next = current as isize + delta; | |
| 612 | + | next.clamp(0, len as isize - 1) as usize | |
| 613 | + | } | |
| 614 | + | ||
| 502 | 615 | #[cfg(test)] | |
| 503 | 616 | mod tests { | |
| 504 | 617 | use super::*; | |
| @@ -546,10 +659,22 @@ | |||
| 546 | 659 | let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); | |
| 547 | 660 | s.observe(p, now()); | |
| 548 | 661 | let mut m = Model::new(vec![s]); | |
| 549 | - | m.select_tab(1); | |
| 662 | + | // Row 0 is the source line, row 1 its only node. Actions hang off the | |
| 663 | + | // node, so that is where the cursor has to be. | |
| 664 | + | m.selected = 1; | |
| 550 | 665 | m | |
| 551 | 666 | } | |
| 552 | 667 | ||
| 668 | + | fn event(at: DateTime<Utc>, label: &str) -> Event { | |
| 669 | + | Event { | |
| 670 | + | at, | |
| 671 | + | label: label.into(), | |
| 672 | + | status: None, | |
| 673 | + | detail: None, | |
| 674 | + | node_id: None, | |
| 675 | + | } | |
| 676 | + | } | |
| 677 | + | ||
| 553 | 678 | fn payload(at: DateTime<Utc>, nodes: Vec<Node>) -> Payload { | |
| 554 | 679 | let mut p = Payload::new("sando", at); | |
| 555 | 680 | p.nodes = nodes; | |
| @@ -691,50 +816,73 @@ | |||
| 691 | 816 | } | |
| 692 | 817 | ||
| 693 | 818 | #[test] | |
| 694 | - | fn tabs_wrap_in_both_directions() { | |
| 819 | + | fn the_tabs_are_fixed_and_wrap_in_both_directions() { | |
| 820 | + | // Two sources, three tabs: the count no longer follows the config, | |
| 821 | + | // which is the whole of what this restructure changed. | |
| 695 | 822 | let mut m = Model::new(vec![source("a", now(), vec![]), source("b", now(), vec![])]); | |
| 696 | - | assert_eq!(m.tab, Tab::Rollup); | |
| 823 | + | assert_eq!(Tab::titles(), vec!["live", "logs", "store"]); | |
| 824 | + | assert_eq!(m.tab, Tab::Live); | |
| 697 | 825 | m.next_tab(); | |
| 698 | - | assert_eq!(m.tab, Tab::Source(0)); | |
| 826 | + | assert_eq!(m.tab, Tab::Logs); | |
| 699 | 827 | m.next_tab(); | |
| 700 | - | assert_eq!(m.tab, Tab::Source(1)); | |
| 828 | + | assert_eq!(m.tab, Tab::Store); | |
| 701 | 829 | m.next_tab(); | |
| 702 | - | assert_eq!(m.tab, Tab::Rollup); | |
| 830 | + | assert_eq!(m.tab, Tab::Live); | |
| 703 | 831 | m.prev_tab(); | |
| 704 | - | assert_eq!(m.tab, Tab::Source(1)); | |
| 832 | + | assert_eq!(m.tab, Tab::Store); | |
| 705 | 833 | } | |
| 706 | 834 | ||
| 707 | 835 | #[test] | |
| 708 | - | fn enter_on_the_rollup_opens_the_worst_source() { | |
| 709 | - | let mut m = Model::new(vec![ | |
| 836 | + | fn a_tab_out_of_range_is_ignored_rather_than_clamped() { | |
| 837 | + | let mut m = Model::new(vec![source("a", now(), vec![])]); | |
| 838 | + | m.select_tab(1); | |
| 839 | + | assert_eq!(m.tab, Tab::Logs); | |
| 840 | + | m.select_tab(9); | |
| 841 | + | assert_eq!(m.tab, Tab::Logs, "a mistyped digit must not move the tab"); | |
| 842 | + | } | |
| 843 | + | ||
| 844 | + | #[test] | |
| 845 | + | fn the_live_rows_put_each_sources_nodes_under_it_worst_first() { | |
| 846 | + | let mut parent = node("tier:b", Status::Failed, vec!["node:prod-1"]); | |
| 847 | + | parent.children = vec!["node:prod-1".into()]; | |
| 848 | + | let m = Model::new(vec![ | |
| 710 | 849 | source("healthy", now(), vec![node("n", Status::Ok, vec![])]), | |
| 711 | - | source("broken", now(), vec![node("n", Status::Failed, vec![])]), | |
| 850 | + | source( | |
| 851 | + | "broken", | |
| 852 | + | now(), | |
| 853 | + | vec![parent, node("node:prod-1", Status::Ok, vec![])], | |
| 854 | + | ), | |
| 712 | 855 | ]); | |
| 713 | - | m.open_selected(now()); | |
| 714 | - | assert_eq!( | |
| 715 | - | m.tab, | |
| 716 | - | Tab::Source(1), | |
| 717 | - | "the first rollup row is the worst source" | |
| 718 | - | ); | |
| 856 | + | let rows = m.live_rows(now()); | |
| 857 | + | // The failing source leads, then its node, then its child, then the | |
| 858 | + | // healthy source and its node. | |
| 859 | + | assert_eq!(rows.len(), 5); | |
| 860 | + | assert!(matches!(rows[0], LiveRow::Source { index: 1 })); | |
| 861 | + | assert!(matches!(rows[1], LiveRow::Node { depth: 1, .. })); | |
| 862 | + | assert!(matches!(rows[2], LiveRow::Node { depth: 2, .. })); | |
| 863 | + | assert!(matches!(rows[3], LiveRow::Source { index: 0 })); | |
| 864 | + | assert_eq!(rows[1].source_index(), 1, "a node knows its own source"); | |
| 865 | + | assert!(rows[0].node().is_none(), "a source line is not a node"); | |
| 719 | 866 | } | |
| 720 | 867 | ||
| 721 | 868 | #[test] | |
| 722 | 869 | fn selection_cannot_run_off_either_end() { | |
| 723 | - | let mut s = source( | |
| 870 | + | let mut m = Model::new(vec![source( |
Lines truncated
| @@ -22,9 +22,9 @@ | |||
| 22 | 22 | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 23 | 23 | use ratatui::style::{Modifier, Style}; | |
| 24 | 24 | use ratatui::text::{Line, Span}; | |
| 25 | - | use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, TableState, Tabs}; | |
| 25 | + | use ratatui::widgets::{Block, Clear, Paragraph, TableState, Tabs}; | |
| 26 | 26 | ||
| 27 | - | use crate::model::{Model, Prompt, SourceState, Tab}; | |
| 27 | + | use crate::model::{LiveRow, Model, Prompt, SourceState, Tab}; | |
| 28 | 28 | use crate::value; | |
| 29 | 29 | ||
| 30 | 30 | pub(crate) fn render(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame) { | |
| @@ -37,11 +37,9 @@ | |||
| 37 | 37 | ||
| 38 | 38 | render_header(model, theme, now, frame, header); | |
| 39 | 39 | match model.tab { | |
| 40 | - | Tab::Rollup => render_rollup(model, theme, now, frame, body), | |
| 41 | - | Tab::Source(i) => match model.sources.get(i) { | |
| 42 | - | Some(source) => render_source(source, theme, now, frame, body), | |
| 43 | - | None => frame.render_widget(Paragraph::new("no such source").style(muted(theme)), body), | |
| 44 | - | }, | |
| 40 | + | Tab::Live => render_live(model, theme, now, frame, body), | |
| 41 | + | Tab::Logs => render_logs(model, theme, frame, body), | |
| 42 | + | Tab::Store => render_store(theme, frame, body), | |
| 45 | 43 | } | |
| 46 | 44 | // A prompt floats over whatever tab is showing: the state behind it keeps | |
| 47 | 45 | // updating on every poll, which is the point of not blocking on the modal. | |
| @@ -84,7 +82,7 @@ | |||
| 84 | 82 | let worst = model.worst(now); | |
| 85 | 83 | let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area); | |
| 86 | 84 | ||
| 87 | - | // The rollup mark is a filled chip: the worst status is the one thing on | |
| 85 | + | // The worst-status mark is a filled chip: the worst status is the one thing on | |
| 88 | 86 | // screen that has to be readable from across the room, so it takes the | |
| 89 | 87 | // status colour as a background rather than as text. | |
| 90 | 88 | let status = value::status_style(theme, worst); | |
| @@ -99,7 +97,7 @@ | |||
| 99 | 97 | mark, | |
| 100 | 98 | ); | |
| 101 | 99 | frame.render_widget( | |
| 102 | - | Tabs::new(model.tab_titles()) | |
| 100 | + | Tabs::new(Tab::titles()) | |
| 103 | 101 | .select(model.tab_index()) | |
| 104 | 102 | .style(muted(theme)) | |
| 105 | 103 | .highlight_style(selected(theme).add_modifier(Modifier::BOLD)) | |
| @@ -116,7 +114,7 @@ | |||
| 116 | 114 | Style::default().fg(theme.status_warning), | |
| 117 | 115 | ), | |
| 118 | 116 | None => Span::styled( | |
| 119 | - | " tab/shift-tab switch up/down move enter open 1-9 jump q quit", | |
| 117 | + | " tab/shift-tab switch up/down move enter run action 1-3 jump q quit", | |
| 120 | 118 | muted(theme), | |
| 121 | 119 | ), | |
| 122 | 120 | }; | |
| @@ -127,19 +125,19 @@ | |||
| 127 | 125 | } | |
| 128 | 126 | ||
| 129 | 127 | // --------------------------------------------------------------------------- | |
| 130 | - | // Rollup | |
| 128 | + | // Live | |
| 131 | 129 | // --------------------------------------------------------------------------- | |
| 132 | 130 | ||
| 133 | - | /// The rollup's columns, left to right. | |
| 131 | + | /// The live tab's columns, left to right. | |
| 134 | 132 | /// | |
| 135 | 133 | /// The mark's name is empty because the name is what the header row draws, and | |
| 136 | 134 | /// this column's header always was blank: the glyph says what it is. | |
| 137 | 135 | /// | |
| 138 | - | /// Nothing here is `Optional`. A rollup with the status or the source name | |
| 139 | - | /// dropped is not a narrower rollup, it is a different screen, and the age is | |
| 136 | + | /// Nothing here is `Optional`. A live view with the status or the source name | |
| 137 | + | /// dropped is not a narrower view, it is a different screen, and the age is | |
| 140 | 138 | /// what turns "FAIL" into "FAIL, and it has been that way for two days". Detail | |
| 141 | 139 | /// absorbs what is left, which is what the old `Min(10)` was saying. | |
| 142 | - | const ROLLUP_COLUMNS: [Column<'static>; 4] = [ | |
| 140 | + | const LIVE_COLUMNS: [Column<'static>; 4] = [ | |
| 143 | 141 | Column { | |
| 144 | 142 | name: "", | |
| 145 | 143 | width: Width::Fixed, | |
| @@ -174,41 +172,74 @@ | |||
| 174 | 172 | /// re-chosen. The two `Width::Content` columns measure themselves from the | |
| 175 | 173 | /// cells and use these only as a floor, so a run of short source names stops | |
| 176 | 174 | /// spending fourteen columns to say `pom`. | |
| 177 | - | const ROLLUP_SIZING: Sizing<'static> = Sizing { | |
| 175 | + | const LIVE_SIZING: Sizing<'static> = Sizing { | |
| 178 | 176 | lengths: &[("", 4), ("source", 14), ("age", 8), ("detail", 10)], | |
| 179 | 177 | fallback: 8, | |
| 180 | 178 | }; | |
| 181 | 179 | ||
| 182 | - | /// Every source at once, worst first. | |
| 180 | + | /// Every source at once, worst first, with each source's nodes under it. | |
| 183 | 181 | /// | |
| 184 | - | /// Without this magicmirror is N tabs you still have to visit one at a time, | |
| 185 | - | /// which is the situation it replaces, with extra steps. | |
| 186 | - | fn render_rollup(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 187 | - | let order = model.rollup_order(now); | |
| 188 | - | let rows: Vec<Vec<TableCell>> = order | |
| 182 | + | /// The nodes are indented into the same table rather than given a pane of their | |
| 183 | + | /// own: there is one cursor, and what it is on is what the detail pane below | |
| 184 | + | /// explains. That is the whole of what the per-source tabs used to do, minus | |
| 185 | + | /// the visiting them one at a time. | |
| 186 | + | fn render_live(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 187 | + | // The table takes the majority and the detail pane what is left. A fixed | |
| 188 | + | // height for the detail would eat the whole body on a short terminal, which | |
| 189 | + | // is the one case where the list is the thing you need. | |
| 190 | + | let [table_area, detail_area] = | |
| 191 | + | Layout::vertical([Constraint::Percentage(60), Constraint::Min(3)]).areas(area); | |
| 192 | + | ||
| 193 | + | let live = model.live_rows(now); | |
| 194 | + | let rows: Vec<Vec<TableCell>> = live | |
| 189 | 195 | .iter() | |
| 190 | - | .map(|&index| { | |
| 191 | - | let source = &model.sources[index]; | |
| 192 | - | let status = source.status(now); | |
| 193 | - | let age = match source.age(now) { | |
| 194 | - | Some(age) => value::duration(age.num_seconds()), | |
| 195 | - | None => "-".into(), | |
| 196 | - | }; | |
| 197 | - | vec![ | |
| 198 | - | // The mark styles its own span rather than the cell: a status | |
| 199 | - | // colour is this app's, not a part the table module knows, and | |
| 200 | - | // a span's style sits on top of the cell's. | |
| 196 | + | .map(|row| match row { | |
| 197 | + | LiveRow::Source { index } => { | |
| 198 | + | let source = &model.sources[*index]; | |
| 199 | + | let status = source.status(now); | |
| 200 | + | let age = match source.age(now) { | |
| 201 | + | Some(age) => value::duration(age.num_seconds()), | |
| 202 | + | None => "-".into(), | |
| 203 | + | }; | |
| 204 | + | vec![ | |
| 205 | + | // The mark styles its own span rather than the cell: a | |
| 206 | + | // status colour is this app's, not a part the table module | |
| 207 | + | // knows, and a span's style sits on top of the cell's. | |
| 208 | + | TableCell::new( | |
| 209 | + | "", | |
| 210 | + | Span::styled( | |
| 211 | + | value::status_mark(status), | |
| 212 | + | value::status_style(theme, status), | |
| 213 | + | ), | |
| 214 | + | ), | |
| 215 | + | TableCell::new( | |
| 216 | + | "source", | |
| 217 | + | Span::styled( | |
| 218 | + | source.name.clone(), | |
| 219 | + | Style::default() | |
| 220 | + | .fg(theme.content_primary) | |
| 221 | + | .add_modifier(Modifier::BOLD), | |
| 222 | + | ), | |
| 223 | + | ), | |
| 224 | + | TableCell::new("age", Span::styled(age, muted(theme))), | |
| 225 | + | TableCell::new("detail", source.summary(now)), | |
| 226 | + | ] | |
| 227 | + | } | |
| 228 | + | LiveRow::Node { node, depth, .. } => vec![ | |
| 201 | 229 | TableCell::new( | |
| 202 | 230 | "", | |
| 203 | 231 | Span::styled( | |
| 204 | - | value::status_mark(status), | |
| 205 | - | value::status_style(theme, status), | |
| 232 | + | value::status_mark(node.status), | |
| 233 | + | value::status_style(theme, node.status), | |
| 206 | 234 | ), | |
| 207 | 235 | ), | |
| 208 | - | TableCell::new("source", source.name.clone()), | |
| 209 | - | TableCell::new("age", Span::styled(age, muted(theme))), | |
| 210 | - | TableCell::new("detail", source.summary(now)), | |
| 211 | - | ] | |
| 236 | + | TableCell::new("source", format!("{}{}", " ".repeat(*depth), node.label)), | |
| 237 | + | // A node has no age of its own; the source line above it carries | |
| 238 | + | // the one age there is, and repeating it would say that each node | |
| 239 | + | // was measured separately. | |
| 240 | + | TableCell::new("age", Span::styled(String::new(), muted(theme))), | |
| 241 | + | TableCell::new("detail", Span::styled(node.kind.clone(), muted(theme))), | |
| 242 | + | ], | |
| 212 | 243 | }) | |
| 213 | 244 | .collect(); | |
| 214 | 245 | ||
| @@ -217,11 +248,11 @@ | |||
| 217 | 248 | // columns of border is the difference between "detail fits" and "detail | |
| 218 | 249 | // is cut", which is exactly the decision the cutoff is making. | |
| 219 | 250 | let block = container(theme, " all sources "); | |
| 220 | - | let inner = block.inner(area); | |
| 251 | + | let inner = block.inner(table_area); | |
| 221 | 252 | let table = table::table( | |
| 222 | - | &ROLLUP_COLUMNS, | |
| 253 | + | &LIVE_COLUMNS, | |
| 223 | 254 | &rows, | |
| 224 | - | &ROLLUP_SIZING, | |
| 255 | + | &LIVE_SIZING, | |
| 225 | 256 | &TableStyle::from_theme(theme), | |
| 226 | 257 | inner.width, | |
| 227 | 258 | ) | |
| @@ -231,75 +262,19 @@ | |||
| 231 | 262 | // `TableStyle::from_theme` carries it on the background alone, which is what | |
| 232 | 263 | // leaves a FAIL row's danger colour on top of it -- the same reason the | |
| 233 | 264 | // local `selected` helper gives a surface instead of reversing. | |
| 234 | - | let mut state = TableState::default().with_selected(Some(model.rollup_selected)); | |
| 235 | - | frame.render_stateful_widget(table, area, &mut state); | |
| 236 | - | } | |
| 265 | + | let mut state = TableState::default().with_selected(Some(model.selected)); | |
| 266 | + | frame.render_stateful_widget(table, table_area, &mut state); | |
| 237 | 267 | ||
| 238 | - | // --------------------------------------------------------------------------- | |
| 239 | - | // One source | |
| 240 | - | // --------------------------------------------------------------------------- | |
| 241 | - | ||
| 242 | - | fn render_source( | |
| 243 | - | source: &SourceState, | |
| 244 | - | theme: &Theme, | |
| 245 | - | now: DateTime<Utc>, | |
| 246 | - | frame: &mut Frame, | |
| 247 | - | area: Rect, | |
| 248 | - | ) { | |
| 249 | - | let [list_area, detail_area] = | |
| 250 | - | Layout::vertical([Constraint::Percentage(55), Constraint::Min(5)]).areas(area); | |
| 251 | - | ||
| 252 | - | let rows = source.rows(); | |
| 253 | - | let items: Vec<ListItem> = rows | |
| 254 | - | .iter() | |
| 255 | - | .enumerate() | |
| 256 | - | .map(|(i, row)| { | |
| 257 | - | let indent = " ".repeat(row.depth); | |
| 258 | - | let spans = vec![ | |
| 259 | - | Span::styled( | |
| 260 | - | format!("{:<5}", value::status_mark(row.node.status)), | |
| 261 | - | value::status_style(theme, row.node.status), | |
| 262 | - | ), | |
| 263 | - | Span::styled( | |
| 264 | - | format!("{indent}{}", row.node.label), | |
| 265 | - | Style::default().fg(theme.content_primary), | |
| 266 | - | ), | |
| 267 | - | Span::styled(format!(" ({})", row.node.kind), muted(theme)), | |
| 268 | - | ]; | |
| 269 | - | // Selection is the row's surface, applied under the spans rather | |
| 270 | - | // than patched into each one, so a status colour survives being | |
| 271 | - | // selected. | |
| 272 | - | let item = ListItem::new(Line::from(spans)); | |
| 273 | - | if i == source.selected { | |
| 274 | - | item.style(selected(theme)) | |
| 275 | - | } else { | |
| 276 | - | item | |
| 277 | - | } | |
| 278 | - | }) | |
| 279 | - | .collect(); | |
| 280 | - | ||
| 281 | - | let title = format!(" {} ", source.name); | |
| 282 | - | let list = if items.is_empty() { | |
| 283 | - | List::new(vec![ListItem::new(Line::from(Span::styled( | |
| 284 | - | source.summary(now), | |
| 285 | - | muted(theme), | |
| 286 | - | )))]) | |
| 287 | - | } else { | |
| 288 | - | List::new(items) | |
| 289 | - | }; | |
| 290 | - | frame.render_widget( | |
| 291 | - | list.block( | |
| 292 | - | Block::bordered() | |
| 293 | - | .border_style(Style::default().fg(theme.line_border)) | |
| 294 | - | .title(Span::styled( | |
| 295 | - | title, | |
| 296 | - | Style::default().fg(theme.content_secondary), | |
| 297 | - | )), | |
| 298 | - | ), | |
| 299 | - | list_area, | |
| 268 | + | let row = live.get(model.selected); | |
| 269 | + | let source = row.map(|r| &model.sources[r.source_index()]); | |
| 270 | + | render_detail( | |
| 271 | + | source, | |
| 272 | + | row.and_then(LiveRow::node), | |
| 273 | + | theme, | |
| 274 | + | now, | |
| 275 | + | frame, | |
| 276 | + | detail_area, | |
| 300 | 277 | ); | |
| 301 | - | ||
| 302 | - | render_detail(source, theme, now, frame, detail_area); | |
| 303 | 278 | } | |
| 304 | 279 | ||
| 305 | 280 | /// The selected node's fields and conditions. | |
| @@ -308,7 +283,8 @@ | |||
| 308 | 283 | /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves | |
| 309 | 284 | /// an SSH. | |
| 310 | 285 | fn render_detail( | |
| 311 | - | source: &SourceState, | |
| 286 | + | source: Option<&SourceState>, | |
| 287 | + | node: Option<&Node>, | |
| 312 | 288 | theme: &Theme, | |
| 313 | 289 | now: DateTime<Utc>, | |
| 314 | 290 | frame: &mut Frame, | |
| @@ -317,7 +293,7 @@ | |||
| 317 | 293 | let width = area.width.saturating_sub(4) as usize; | |
| 318 | 294 | let mut lines: Vec<Line> = Vec::new(); | |
| 319 | 295 | ||
| 320 | - | match source.selected_node() { | |
| 296 | + | match node { | |
| 321 | 297 | Some(node) => { | |
| 322 | 298 | lines.push(Line::from(vec![ | |
| 323 | 299 | Span::styled( | |
| @@ -338,7 +314,7 @@ | |||
| 338 | 314 | // The hint tells the operator whether Enter does anything here, | |
| 339 | 315 | // so a read-only source does not look broken when a keypress is | |
| 340 | 316 | // ignored. | |
| 341 | - | let hint = if source.allow_actions { | |
| 317 | + | let hint = if source.is_some_and(|s| s.allow_actions) { | |
| 342 | 318 | " (enter to run)" | |
| 343 | 319 | } else { | |
| 344 | 320 | " (read-only)" | |
| @@ -352,7 +328,13 @@ | |||
| 352 | 328 | ])); | |
| 353 | 329 | } | |
| 354 | 330 | } | |
| 355 | - | None => lines.push(Line::from(Span::styled(source.summary(now), muted(theme)))), | |
| 331 | + | // A source line, or an empty list. Either way the source's own summary | |
| 332 | + | // is the thing worth saying: it is why the source has no nodes to | |
| 333 | + | // select, when it has none. | |
| 334 | + | None => lines.push(Line::from(Span::styled( | |
| 335 | + | source.map_or_else(|| "no sources".to_string(), |source| source.summary(now)), | |
| 336 | + | muted(theme), | |
| 337 | + | ))), | |
| 356 | 338 | } | |
| 357 | 339 | ||
| 358 | 340 | frame.render_widget( | |
| @@ -361,6 +343,147 @@ | |||
| 361 | 343 | ); | |
| 362 | 344 | } | |
| 363 | 345 | ||
| 346 | + | // --------------------------------------------------------------------------- | |
| 347 | + | // Logs | |
| 348 | + | // --------------------------------------------------------------------------- | |
| 349 | + | ||
| 350 | + | /// The logs tab's columns. | |
| 351 | + | /// | |
| 352 | + | /// The source is a column on every line rather than a heading over a section, | |
| 353 | + | /// so a line read on its own still says who said it. The grouping is still | |
| 354 | + | /// there: the rows arrive grouped by source and the column makes the boundaries | |
| 355 | + | /// visible without costing an index that does not line up with the cursor. | |
| 356 | + | const LOG_COLUMNS: [Column<'static>; 5] = [ | |
| 357 | + | Column { | |
| 358 | + | name: "when", | |
| 359 | + | width: Width::Content, | |
| 360 | + | priority: Priority::Essential, | |
| 361 | + | sortable: false, | |
| 362 | + | sorted: None, | |
| 363 | + | }, | |
| 364 | + | Column { | |
| 365 | + | name: "source", | |
| 366 | + | width: Width::Content, | |
| 367 | + | priority: Priority::Essential, | |
| 368 | + | sortable: false, | |
| 369 | + | sorted: None, | |
| 370 | + | }, | |
| 371 | + | Column { | |
| 372 | + | name: "", | |
| 373 | + | width: Width::Fixed, | |
| 374 | + | priority: Priority::Secondary, | |
| 375 | + | sortable: false, | |
| 376 | + | sorted: None, | |
| 377 | + | }, | |
| 378 | + | Column { | |
| 379 | + | name: "event", | |
| 380 | + | width: Width::Content, | |
| 381 | + | priority: Priority::Essential, | |
| 382 | + | sortable: false, | |
| 383 | + | sorted: None, | |
| 384 | + | }, | |
| 385 | + | Column { | |
| 386 | + | name: "detail", | |
| 387 | + | width: Width::Fill, | |
| 388 | + | priority: Priority::Optional, | |
| 389 | + | sortable: false, | |
| 390 | + | sorted: None, | |
| 391 | + | }, | |
| 392 | + | ]; | |
| 393 | + | ||
| 394 | + | const LOG_SIZING: Sizing<'static> = Sizing { | |
| 395 | + | lengths: &[ | |
| 396 | + | ("when", 9), | |
| 397 | + | ("source", 10), | |
| 398 | + | ("", 4), | |
| 399 | + | ("event", 20), | |
| 400 | + | ("detail", 10), | |
| 401 | + | ], | |
| 402 | + | fallback: 8, | |
| 403 | + | }; | |
| 404 | + | ||
| 405 | + | /// What every source has said lately, grouped by which one said it. | |
| 406 | + | /// | |
| 407 | + | /// The events are contract (`ops_status::Event`) and nothing here knows what any | |
| 408 | + | /// of them mean, which is the same bargain the rest of the shell makes: a new | |
| 409 | + | /// daemon that emits events gets this screen for free. | |
| 410 | + | fn render_logs(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) { | |
| 411 | + | let logs = model.log_rows(); | |
| 412 | + | if logs.is_empty() { | |
| 413 | + | frame.render_widget( | |
| 414 | + | Paragraph::new(Line::from(Span::styled( | |
| 415 | + | "no source has reported an event", | |
| 416 | + | muted(theme), | |
| 417 | + | ))) | |
| 418 | + | .block(container(theme, " logs ")), | |
| 419 | + | area, | |
| 420 | + | ); | |
| 421 | + | return; | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | let rows: Vec<Vec<TableCell>> = logs | |
| 425 | + | .iter() | |
| 426 | + | .map(|row| { | |
| 427 | + | let status = row.event.status; | |
| 428 | + | vec![ | |
| 429 | + | TableCell::new( | |
| 430 | + | "when", | |
| 431 | + | Span::styled(row.event.at.format("%H:%M:%S").to_string(), muted(theme)), | |
| 432 | + | ), | |
| 433 | + | TableCell::new("source", row.source.to_string()), | |
| 434 | + | TableCell::new( | |
| 435 | + | "", | |
| 436 | + | match status { | |
| 437 | + | Some(status) => Span::styled( | |
| 438 | + | value::status_mark(status), | |
| 439 | + | value::status_style(theme, status), | |
| 440 | + | ), | |
| 441 | + | // An event with no status is a note, not a verdict. | |
| 442 | + | // Blank rather than a guessed mark: inventing "ok" here | |
| 443 | + | // is exactly the domain knowledge the shell refuses. | |
| 444 | + | None => Span::raw(""), | |
| 445 | + | }, | |
| 446 | + | ), | |
| 447 | + | TableCell::new("event", row.event.label.clone()), | |
| 448 | + | TableCell::new( | |
| 449 | + | "detail", | |
| 450 | + | Span::styled(row.event.detail.clone().unwrap_or_default(), muted(theme)), | |
| 451 | + | ), | |
| 452 | + | ] | |
| 453 | + | }) | |
| 454 | + | .collect(); | |
| 455 | + | ||
| 456 | + | let block = container(theme, " logs "); | |
| 457 | + | let inner = block.inner(area); | |
| 458 | + | let table = table::table( | |
| 459 | + | &LOG_COLUMNS, | |
| 460 | + | &rows, | |
| 461 | + | &LOG_SIZING, | |
| 462 | + | &TableStyle::from_theme(theme), | |
| 463 | + | inner.width, | |
| 464 | + | ) | |
| 465 | + | .block(block); | |
| 466 | + | let mut state = TableState::default().with_selected(Some(model.logs_scroll)); | |
| 467 | + | frame.render_stateful_widget(table, area, &mut state); | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // --------------------------------------------------------------------------- | |
| 471 | + | // Store | |
| 472 | + | // --------------------------------------------------------------------------- | |
| 473 | + | ||
| 474 | + | /// The store view. Infra `9d0e7098` fills this in; the tab exists now so the | |
| 475 | + | /// shape shipped here is the shape that gets one more arm rather than a rewrite. | |
| 476 | + | fn render_store(theme: &Theme, frame: &mut Frame, area: Rect) { | |
| 477 | + | frame.render_widget( | |
| 478 | + | Paragraph::new(Line::from(Span::styled( | |
| 479 | + | "no store configured", | |
| 480 | + | muted(theme), | |
| 481 | + | ))) | |
| 482 | + | .block(container(theme, " store ")), | |
| 483 | + | area, | |
| 484 | + | ); | |
| 485 | + | } | |
| 486 | + | ||
| 364 | 487 | fn field_lines(theme: &Theme, node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> { | |
| 365 | 488 | let label_width = node | |
| 366 | 489 | .fields | |
| @@ -649,7 +772,7 @@ | |||
| 649 | 772 | } | |
| 650 | 773 | ||
| 651 | 774 | #[test] | |
| 652 | - | fn the_rollup_leads_with_the_worst_source() { | |
| 775 | + | fn the_live_tab_leads_with_the_worst_source() { | |
| 653 | 776 | let model = Model::new(vec![ | |
| 654 | 777 | source("sando", now(), vec![node("a", "tier a", Status::Ok)]), | |
| 655 | 778 | source("bento", now(), vec![node("b", "goingson", Status::Failed)]), | |
| @@ -657,8 +780,13 @@ | |||
| 657 | 780 | let lines = draw(&model, now(), 80, 12); | |
| 658 | 781 | let text = joined(&lines); | |
| 659 | 782 | ||
| 660 | - | assert!(text.contains("rollup"), "{text}"); | |
| 661 | - | // Skip the tab bar, which names every source regardless of order. | |
| 783 | + | assert!( | |
| 784 | + | text.contains("live"), | |
| 785 | + | "the tab bar names the fixed tabs:\n{text}" | |
| 786 | + | ); | |
| 787 | + | assert!(text.contains("logs"), "{text}"); | |
| 788 | + | assert!(text.contains("store"), "{text}"); | |
| 789 | + | // Skip the tab bar, which names every tab regardless of order. | |
| 662 | 790 | let body = &lines[1..]; | |
| 663 | 791 | let bento = body.iter().position(|l| l.contains("bento")).unwrap(); | |
| 664 | 792 | let sando = body.iter().position(|l| l.contains("sando")).unwrap(); | |
| @@ -666,6 +794,26 @@ | |||
| 666 | 794 | assert!(text.contains("FAIL"), "{text}"); | |
| 667 | 795 | } | |
| 668 | 796 | ||
| 797 | + | #[test] | |
| 798 | + | fn no_source_gets_a_tab_of_its_own() { | |
| 799 | + | // The restructure, asserted directly: two sources, three tabs, and the | |
| 800 | + | // tab bar names none of them. | |
| 801 | + | let model = Model::new(vec![ | |
| 802 | + | source("sando", now(), vec![node("a", "tier a", Status::Ok)]), | |
| 803 | + | source("bento", now(), vec![node("b", "goingson", Status::Ok)]), | |
| 804 | + | ]); | |
| 805 | + | let bar = draw(&model, now(), 80, 12)[0].clone(); | |
| 806 | + | assert!( | |
| 807 | + | bar.contains("live") && bar.contains("logs") && bar.contains("store"), | |
| 808 | + | "{bar}" | |
| 809 | + | ); | |
| 810 | + | assert!( | |
| 811 | + | !bar.contains("sando"), | |
| 812 | + | "a source must not own a tab:\n{bar}" | |
| 813 | + | ); |
Lines truncated