Skip to main content

max / makenotwork

Replace magicmirror's per-source tabs with three fixed tabs The rollup existed to end the situation where you visited N screens one at a time, and the N screens were still shipping beside it. They are gone. Three fixed tabs now, whatever the config holds: - live: every source worst-first, with each source's nodes indented under it and a detail pane for whatever the cursor is on. This is the rollup plus what the per-source tabs held, on one cursor. - logs: every source's events, grouped by source in name order and newest first within each. Contract already carried these (ops_status::Event); no producer changes, so a daemon that emits events gets the screen for free. - store: the tab exists and says it has nothing. Infra 9d0e7098 fills it in. Actions kept their reachability: Enter on a node row in the live tab opens that node's picker, which is where the per-source tabs' one irreplaceable job landed. A source line has no actions of its own and does nothing. The cursor moved to the model. With one list spanning every source, no single source can clamp it after a poll that shortened it, so the clamp runs once per applied update instead of inside observe. Digit keys are 1-based over the fixed tabs. Infra 09c0b3d4.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 22:43 UTC
Signed with PGP, not checked
Commit: f9d866b1035a4d2f1bf83ad9038567283ee6c8f7
Parent: d95c3af
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