Skip to main content

max / makenotwork

Build magicmirror's store tab: config-declared series The third tab renders the series a [[store]] block names, read out of a producer's own SQLite file. witchbroom's store is one (series, labels, value, at) table with an f64 and no way for the schema to say what a series means, so the meaning comes from magicmirror's config: a series is named with its label and unit, and a series nobody named is not shown. Silence over noise, per infra 7bafb5dd. No fallback renders unlabelled rows; that fallback is the table browser the ruling rejected, arriving by the back door. Two costs, both accepted on that ruling. rusqlite enters a crate that had no database dependency, bundled to match witchbroom and wam so the tree keeps one libsqlite3. And "renders the ops-status contract and nothing else" is broken, narrowly: read-only, no CREATE, one query per configured series, nothing discovered and nothing inferred. Three states are visible rather than collapsed. A series with readings shows one row per label set, newest value, with its age. A named series the store holds nothing for still gets a row saying so, because a soak target that never reported is the thing worth noticing and omitting it would look like never having configured it. A store that is missing or unreadable shows an unavailable row above whatever it last said, so stale numbers are never read as current. Read on its own slow interval into the model rather than on draw, which is what keeps render a pure function of model, theme and clock and the whole surface testable without a database. Infra 9d0e7098.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 22:49 UTC
Signed with PGP, not checked
Commit: 796ba5a2ded3f389c1aeb6e7449b40995d2b055d
Parent: f9d866b
8 files changed, +1239 insertions, -58 deletions
@@ -413,6 +413,18 @@
413 413 "num-traits",
414 414 ]
415 415
416 + [[package]]
417 + name = "fallible-iterator"
418 + version = "0.3.0"
419 + source = "registry+https://github.com/rust-lang/crates.io-index"
420 + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
421 +
422 + [[package]]
423 + name = "fallible-streaming-iterator"
424 + version = "0.1.9"
425 + source = "registry+https://github.com/rust-lang/crates.io-index"
426 + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
427 +
416 428 [[package]]
417 429 name = "fancy-regex"
418 430 version = "0.11.0"
@@ -584,6 +596,15 @@
584 596 "foldhash",
585 597 ]
586 598
599 + [[package]]
600 + name = "hashlink"
601 + version = "0.12.1"
602 + source = "registry+https://github.com/rust-lang/crates.io-index"
603 + checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
604 + dependencies = [
605 + "hashbrown 0.17.1",
606 + ]
607 +
587 608 [[package]]
588 609 name = "heck"
589 610 version = "0.5.0"
@@ -993,6 +1014,17 @@
993 1014 source = "registry+https://github.com/rust-lang/crates.io-index"
994 1015 checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
995 1016
1017 + [[package]]
1018 + name = "libsqlite3-sys"
1019 + version = "0.38.2"
1020 + source = "registry+https://github.com/rust-lang/crates.io-index"
1021 + checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
1022 + dependencies = [
1023 + "cc",
1024 + "pkg-config",
1025 + "vcpkg",
1026 + ]
1027 +
996 1028 [[package]]
997 1029 name = "line-clipping"
998 1030 version = "0.3.7"
@@ -1056,7 +1088,7 @@
1056 1088
1057 1089 [[package]]
1058 1090 name = "magicmirror"
1059 - version = "0.2.0"
1091 + version = "0.3.0"
1060 1092 dependencies = [
1061 1093 "anyhow",
1062 1094 "chrono",
@@ -1065,6 +1097,7 @@
1065 1097 "ops-status",
1066 1098 "ratatui",
1067 1099 "reqwest",
1100 + "rusqlite",
1068 1101 "rustls",
1069 1102 "serde",
1070 1103 "serde_json",
@@ -1381,6 +1414,12 @@
1381 1414 source = "registry+https://github.com/rust-lang/crates.io-index"
1382 1415 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1383 1416
1417 + [[package]]
1418 + name = "pkg-config"
1419 + version = "0.3.34"
1420 + source = "registry+https://github.com/rust-lang/crates.io-index"
1421 + checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
1422 +
1384 1423 [[package]]
1385 1424 name = "portable-atomic"
1386 1425 version = "1.14.0"
@@ -1636,6 +1675,31 @@
1636 1675 "windows-sys 0.52.0",
1637 1676 ]
1638 1677
1678 + [[package]]
1679 + name = "rsqlite-vfs"
1680 + version = "0.1.1"
1681 + source = "registry+https://github.com/rust-lang/crates.io-index"
1682 + checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
1683 + dependencies = [
1684 + "hashbrown 0.16.1",
1685 + "thiserror 2.0.19",
1686 + ]
1687 +
1688 + [[package]]
1689 + name = "rusqlite"
1690 + version = "0.40.2"
1691 + source = "registry+https://github.com/rust-lang/crates.io-index"
1692 + checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
1693 + dependencies = [
1694 + "bitflags 2.13.1",
1695 + "fallible-iterator",
1696 + "fallible-streaming-iterator",
1697 + "hashlink",
1698 + "libsqlite3-sys",
1699 + "smallvec",
1700 + "sqlite-wasm-rs",
1701 + ]
1702 +
1639 1703 [[package]]
1640 1704 name = "rustc_version"
1641 1705 version = "0.4.1"
@@ -1941,6 +2005,18 @@
1941 2005 "windows-sys 0.61.2",
1942 2006 ]
1943 2007
2008 + [[package]]
2009 + name = "sqlite-wasm-rs"
2010 + version = "0.5.5"
2011 + source = "registry+https://github.com/rust-lang/crates.io-index"
2012 + checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
2013 + dependencies = [
2014 + "cc",
2015 + "js-sys",
2016 + "rsqlite-vfs",
2017 + "wasm-bindgen",
2018 + ]
2019 +
1944 2020 [[package]]
1945 2021 name = "stable_deref_trait"
1946 2022 version = "1.2.1"
@@ -2428,6 +2504,12 @@
2428 2504 "wasm-bindgen",
2429 2505 ]
2430 2506
2507 + [[package]]
2508 + name = "vcpkg"
2509 + version = "0.2.15"
2510 + source = "registry+https://github.com/rust-lang/crates.io-index"
2511 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2512 +
2431 2513 [[package]]
2432 2514 name = "version_check"
2433 2515 version = "0.9.5"
@@ -2902,6 +2984,30 @@
2902 2984 source = "registry+https://github.com/rust-lang/crates.io-index"
2903 2985 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2904 2986
2987 + [[patch.unused]]
2988 + name = "quasi-type"
2989 + version = "0.1.0"
2990 +
2991 + [[patch.unused]]
2992 + name = "synckit-client"
2993 + version = "0.9.1"
2994 +
2995 + [[patch.unused]]
2996 + name = "synckit-config"
2997 + version = "0.2.0"
2998 +
2999 + [[patch.unused]]
3000 + name = "kberg"
3001 + version = "0.1.0"
3002 +
3003 + [[patch.unused]]
3004 + name = "painhours"
3005 + version = "0.1.0"
3006 +
3007 + [[patch.unused]]
3008 + name = "tagtree"
3009 + version = "0.4.1"
3010 +
2905 3011 [[patch.unused]]
2906 3012 name = "quasi-axum"
2907 3013 version = "0.56.0"
@@ -2941,27 +3047,3 @@
2941 3047 [[patch.unused]]
2942 3048 name = "docengine"
2943 3049 version = "0.7.0"
2944 -
2945 - [[patch.unused]]
2946 - name = "kberg"
2947 - version = "0.1.0"
2948 -
2949 - [[patch.unused]]
2950 - name = "painhours"
2951 - version = "0.1.0"
2952 -
2953 - [[patch.unused]]
2954 - name = "tagtree"
2955 - version = "0.4.1"
2956 -
2957 - [[patch.unused]]
2958 - name = "quasi-type"
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,9 +1,9 @@
1 1 [package]
2 2 name = "magicmirror"
3 - version = "0.2.0"
3 + version = "0.3.0"
4 4 edition = "2024"
5 5 license = "MIT"
6 - description = "One terminal surface over every operator daemon that emits an ops-status payload. Tabs per source plus a worst-first rollup."
6 + description = "One terminal surface over every operator daemon that emits an ops-status payload. Three tabs: live statuses worst-first, logs by source, and configured store series."
7 7
8 8 [[bin]]
9 9 name = "magicmirror"
@@ -26,6 +26,12 @@
26 26 # built. Trust still comes from the OS store via reqwest's platform verifier.
27 27 reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider"] }
28 28 rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "logging", "ring"] }
29 + # The store tab reads a producer's own SQLite database directly, which is the
30 + # one place magicmirror renders something other than the `ops-status` contract.
31 + # The break is deliberate and narrow (infra `7bafb5dd`): a store is read-only,
32 + # read by series names the config declares, and nothing else here touches it.
33 + # `bundled` matches witchbroom and wam, so the whole tree links one libsqlite3.
34 + rusqlite = { version = "0.40", features = ["bundled"] }
29 35 serde = { version = "1.0.228", features = ["derive"] }
30 36 serde_json = "1"
31 37 toml = "1.1"
@@ -7,6 +7,17 @@
7 7 # nothing about tiers, gates, apps or targets; it renders whatever the source
8 8 # emits. A new producer gets a UI by emitting the payload.
9 9 #
10 + # Three tabs, and there are three however many sources are configured:
11 + #
12 + # 1 live every source at once, worst first, each source's nodes indented
13 + # under it, with a detail pane for whatever the cursor is on.
14 + # 2 logs what every source has reported lately, grouped by which one said
15 + # it and newest first within each.
16 + # 3 store the series a [[store]] below names. Empty unless one is declared.
17 + #
18 + # tab/shift-tab or 1-3 to switch, up/down to move, enter to run the selected
19 + # node's action, q to quit.
20 + #
10 21 # A source sets exactly one of `url` and `path`. `url` is a daemon to poll, with
11 22 # /status.json appended. `path` is a payload on disk, which is what a batch
12 23 # producer leaves behind: something that runs for hours and then stops has
@@ -17,7 +28,8 @@
17 28 #
18 29 # magicmirror is read-only until a source sets allow_actions = true. A source
19 30 # with it off still shows the actions its nodes declare; it just refuses to
20 - # fire them. With it on, Enter on a node opens a picker of that node's actions;
31 + # fire them. With it on, Enter on a node row in the live tab opens a picker of
32 + # that node's actions;
21 33 # a plain action fires, a confirm action asks for 'y', and a danger action
22 34 # (sando's rollback-b) asks you to type its key. This keeps magicmirror that is
23 35 # merely pointed at a daemon from ever moving it by accident.
@@ -105,3 +117,49 @@
105 117 path = "~/.local/state/witchbroom/status.json"
106 118 poll_secs = 60
107 119 stale_after_secs = 129600
120 +
121 + # ---------------------------------------------------------------------------
122 + # Stores: the third tab.
123 + # ---------------------------------------------------------------------------
124 + #
125 + # Optional, and normally absent. Everything above is the `ops-status` contract,
126 + # which is what lets a new daemon arrive with a UI already written. A store is
127 + # the one exception: a producer's own SQLite file, opened read-only, for exactly
128 + # the series named below. The break is deliberate and stays narrow.
129 + #
130 + # Why the meaning is here rather than in the data. witchbroom's store is one
131 + # `(series, labels, value, at)` table with an `f64`, on purpose: a new metric is
132 + # a new string rather than a migration, and the accepted cost is that the schema
133 + # cannot say what a number is or what unit it is in. So either every series gets
134 + # rendered unlabelled, which is a table browser, or the operator names the ones
135 + # worth watching and says what each is. The second was chosen.
136 + #
137 + # The consequence, and it is the point rather than a limitation: A SERIES WITH NO
138 + # ENTRY BELOW IS NOT SHOWN. Silence over noise. If a number is missing from the
139 + # tab, the fix is a [[store.series]] here, not a fallback in the code.
140 + #
141 + # A named series the store holds nothing for still gets a row, saying so. A soak
142 + # target that has never reported is exactly the thing worth noticing, and
143 + # omitting it would look identical to never having configured it. A store that
144 + # is missing or unreadable shows an `unavailable` row above whatever it last
145 + # said, so old numbers are never mistaken for current ones.
146 + #
147 + # [[store]]
148 + # name = "witchbroom"
149 + # # witchbroom's own observations.db default. Opened read-only, never created:
150 + # # a typo here stays a visible error rather than becoming an empty database
151 + # # that reads as a producer which has never recorded anything.
152 + # path = "~/.local/state/witchbroom/observations.db"
153 + # # Slower than a source poll by an order of magnitude, because a batch producer
154 + # # writes once at the end of a run that took hours. Defaults to 60.
155 + # poll_secs = 300
156 + #
157 + # [[store.series]]
158 + # series = "soak.coverage_edges" # the store's own series name, verbatim
159 + # label = "Coverage reached" # what goes on screen
160 + # unit = "edges" # optional; some numbers count nothing in particular
161 + #
162 + # [[store.series]]
163 + # series = "cache.size_bytes"
164 + # label = "Compiler cache"
165 + # unit = "bytes"
@@ -21,6 +21,13 @@
21 21 /// looks different from one that is fine.
22 22 const DEFAULT_STALE_SECS: u64 = 60;
23 23
24 + /// Default interval between store reads.
25 + ///
26 + /// Far slower than a source poll, because a store is written by a batch
27 + /// producer: witchbroom's sweep runs for hours and writes once. Re-reading it
28 + /// every five seconds would be spending a query to learn nothing.
29 + const DEFAULT_STORE_POLL_SECS: u64 = 60;
30 +
24 31 #[derive(Debug, Clone, Deserialize)]
25 32 pub(crate) struct Config {
26 33 /// Fallback for any source that does not set its own.
@@ -37,6 +44,48 @@
37 44 pub theme: Option<String>,
38 45 #[serde(default, rename = "source")]
39 46 pub sources: Vec<Source>,
47 + /// Observation stores to render on the store tab.
48 + ///
49 + /// Optional, and normally absent: a store is a producer's own database,
50 + /// read directly, which is the one thing here that is not the `ops-status`
51 + /// contract. Nothing is shown unless it is named below.
52 + #[serde(default, rename = "store")]
53 + pub stores: Vec<Store>,
54 + }
55 +
56 + /// One observation store, and which of its series are worth rendering.
57 + ///
58 + /// **Meaning lives here, not in the data.** witchbroom's store is deliberately
59 + /// `(series, labels, value, at)` with an `f64`, so the schema cannot say what a
60 + /// series is or what unit it is in. Inferring it would mean guessing, and
61 + /// rendering every series unlabelled would make this a table browser, which is
62 + /// what the ruling rejected. So a series is named here with what it means, and
63 + /// a series nobody named is not shown. Silence over noise, on purpose.
64 + #[derive(Debug, Clone, Deserialize)]
65 + pub(crate) struct Store {
66 + /// What to call this store on screen.
67 + pub name: String,
68 + /// The SQLite file. A leading `~` expands at load, same as a file source.
69 + pub path: PathBuf,
70 + #[serde(default = "default_store_poll")]
71 + pub poll_secs: u64,
72 + #[serde(default, rename = "series")]
73 + pub series: Vec<Series>,
74 + }
75 +
76 + /// One series worth rendering, and what it means.
77 + #[derive(Debug, Clone, Deserialize)]
78 + pub(crate) struct Series {
79 + /// The `series` value in the store, verbatim. Spelled `series` in the file,
80 + /// where `[[store.series]]` has already said what kind of thing it is.
81 + #[serde(rename = "series")]
82 + pub name: String,
83 + /// What to call it on screen.
84 + pub label: String,
85 + /// What the number is in. Optional because some numbers are counts of
86 + /// nothing in particular.
87 + #[serde(default)]
88 + pub unit: Option<String>,
40 89 }
41 90
42 91 /// Where one source's payload comes from.
@@ -118,6 +167,10 @@
118 167 DEFAULT_STALE_SECS
119 168 }
120 169
170 + fn default_store_poll() -> u64 {
171 + DEFAULT_STORE_POLL_SECS
172 + }
173 +
121 174 impl Source {
122 175 /// Where this source's payload comes from.
123 176 ///
@@ -174,6 +227,9 @@
174 227 .with_context(|| format!("reading magicmirror config at {}", path.display()))?;
175 228 let mut cfg: Config = toml::from_str(&raw)
176 229 .with_context(|| format!("parsing magicmirror config at {}", path.display()))?;
230 + for store in &mut cfg.stores {
231 + store.path = expand_tilde(&store.path);
232 + }
177 233 for source in &mut cfg.sources {
178 234 if let Some(p) = &source.path {
179 235 source.path = Some(expand_tilde(p));
@@ -229,9 +285,41 @@
229 285 ),
230 286 }
231 287 }
288 +
289 + let mut seen_stores = std::collections::HashSet::new();
290 + for store in &self.stores {
291 + anyhow::ensure!(
292 + seen_stores.insert(store.name.as_str()),
293 + "duplicate store name {:?}: its rows would be ambiguous",
294 + store.name
295 + );
296 + // A store with no series is not a store showing nothing, it is a
297 + // config that can never say anything -- the same refusal a source
298 + // with neither url nor path gets, for the same reason.
299 + anyhow::ensure!(
300 + !store.series.is_empty(),
301 + "store {:?} declares no [[store.series]]: nothing would be rendered, \
302 + and an unnamed series is not shown",
303 + store.name
304 + );
305 + let mut seen_series = std::collections::HashSet::new();
306 + for series in &store.series {
307 + anyhow::ensure!(
308 + seen_series.insert(series.name.as_str()),
309 + "store {:?} names series {:?} twice",
310 + store.name,
311 + series.name
312 + );
313 + }
314 + }
232 315 Ok(())
233 316 }
234 317
318 + /// Interval between reads of one store.
319 + pub(crate) fn store_interval(store: &Store) -> Duration {
320 + Duration::from_secs(store.poll_secs.max(1))
321 + }
322 +
235 323 /// What the operator chose, which is not the same as what is rendered: a
236 324 /// standing "follow the terminal" resolves differently as the terminal
237 325 /// changes, and a pinned id does not.
@@ -317,6 +405,163 @@
317 405 );
318 406 }
319 407
408 + #[test]
409 + fn the_shipped_example_config_parses() {
410 + // The example is the documentation, and it is the file people copy. A
411 + // key renamed in code and not there is a config that fails on first
412 + // run, which is the worst place to find out.
413 + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
414 + .join("deploy/magicmirror.toml.example");
415 + let cfg = Config::load(&path).unwrap_or_else(|e| panic!("{e:?}"));
416 + assert!(!cfg.sources.is_empty());
417 + }
418 +
419 + #[test]
420 + fn the_store_block_in_the_shipped_example_parses_when_uncommented() {
421 + // The store half of the example is commented out because a store is
422 + // optional, which puts it outside the test above. Uncomment it here so
423 + // it cannot drift either.
424 + let raw = std::fs::read_to_string(
425 + std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
426 + .join("deploy/magicmirror.toml.example"),
427 + )
428 + .unwrap();
429 + let uncommented: String = raw
430 + .lines()
431 + .skip_while(|l| !l.starts_with("# [[store]]"))
432 + .map(|l| {
433 + l.strip_prefix("# ")
434 + .or_else(|| l.strip_prefix('#'))
435 + .unwrap_or(l)
436 + })
437 + .collect::<Vec<_>>()
438 + .join("\n");
439 + let cfg: Config = toml::from_str(&uncommented).unwrap_or_else(|e| panic!("{e:?}"));
440 + let store = &cfg.stores[0];
441 + assert_eq!(store.name, "witchbroom");
442 + assert_eq!(store.series.len(), 2);
443 + assert_eq!(store.series[0].name, "soak.coverage_edges");
444 + assert_eq!(store.series[0].label, "Coverage reached");
445 + }
446 +
447 + #[test]
448 + fn a_store_declares_its_series_and_expands_a_tilde_path() {
449 + let (_dir, path) = write(
450 + r#"
451 + [[source]]
452 + name = "sando"
453 + url = "http://fw13:8080"
454 +
455 + [[store]]
456 + name = "witchbroom"
457 + path = "~/.local/state/witchbroom/observations.db"
458 +
459 + [[store.series]]
460 + series = "soak.coverage_edges"
461 + label = "Coverage reached"
462 + unit = "edges"
463 +
464 + [[store.series]]
465 + series = "cache.size_bytes"
466 + label = "Cache size"
467 + "#,
468 + );
469 + let cfg = Config::load(&path).unwrap();
470 + let store = &cfg.stores[0];
471 + assert!(
472 + !store.path.starts_with("~"),
473 + "a store path expands like a source path: {}",
474 + store.path.display()
475 + );
476 + assert_eq!(store.series.len(), 2);
477 + assert_eq!(store.series[0].unit.as_deref(), Some("edges"));
478 + // The unit is genuinely optional: some numbers count nothing in
479 + // particular.
480 + assert_eq!(store.series[1].unit, None);
481 + assert_eq!(
482 + Config::store_interval(store).as_secs(),
483 + DEFAULT_STORE_POLL_SECS
484 + );
485 + }
486 +
487 + #[test]
488 + fn a_config_with_no_store_block_is_fine() {
489 + let (_dir, path) = write(
490 + r#"
491 + [[source]]
492 + name = "sando"
493 + url = "http://fw13:8080"
494 + "#,
495 + );
496 + assert!(Config::load(&path).unwrap().stores.is_empty());
497 + }
498 +
499 + #[test]
500 + fn a_store_with_no_series_is_refused() {
501 + // Not a store showing nothing: a config that can never say anything,
502 + // because an unnamed series is not rendered.
503 + let (_dir, path) = write(
504 + r#"
505 + [[source]]
506 + name = "sando"
507 + url = "http://fw13:8080"
508 +
509 + [[store]]
510 + name = "witchbroom"
511 + path = "/tmp/x.db"
512 + "#,
513 + );
514 + let err = Config::load(&path).unwrap_err().to_string();
515 + assert!(err.contains("store.series"), "{err}");
516 + }
517 +
518 + #[test]
519 + fn a_duplicate_store_or_series_name_is_refused() {
520 + let (_dir, path) = write(
521 + r#"
522 + [[source]]
523 + name = "sando"
524 + url = "http://fw13:8080"
525 +
526 + [[store]]
527 + name = "witchbroom"
528 + path = "/tmp/x.db"
529 + [[store.series]]
530 + series = "a"
531 + label = "A"
532 + [[store.series]]
533 + series = "a"
534 + label = "Also A"
535 + "#,
536 + );
537 + let err = Config::load(&path).unwrap_err().to_string();
538 + assert!(err.contains("twice"), "{err}");
539 +
540 + let (_dir, path) = write(
541 + r#"
542 + [[source]]
543 + name = "sando"
544 + url = "http://fw13:8080"
545 +
546 + [[store]]
547 + name = "witchbroom"
548 + path = "/tmp/x.db"
549 + [[store.series]]
550 + series = "a"
551 + label = "A"
552 +
553 + [[store]]
554 + name = "witchbroom"
555 + path = "/tmp/y.db"
556 + [[store.series]]
557 + series = "b"
558 + label = "B"
559 + "#,
560 + );
561 + let err = Config::load(&path).unwrap_err().to_string();
562 + assert!(err.contains("duplicate store"), "{err}");
563 + }
564 +
320 565 /// The batch-producer shape. witchbroom runs for hours and stops, so it has
321 566 /// nothing to poll; the file it leaves behind is the source.
322 567 #[test]
@@ -11,8 +11,10 @@
11 11 //! the situation it replaces.
12 12 //!
13 13 //! magicmirror knows nothing about tiers, gates, apps, or targets. It renders
14 - //! the shared contract in `ops-status` and nothing else, which is what lets a
15 - //! new daemon arrive with a UI already written.
14 + //! the shared contract in `ops-status`, which is what lets a new daemon arrive
15 + //! with a UI already written. The store tab is the one narrow exception, reading
16 + //! a producer's own database read-only for exactly the series the config names;
17 + //! `store` says why, and why it stays narrow.
16 18 //!
17 19 //! # Boundary
18 20 //!
@@ -25,6 +27,7 @@
25 27 mod model;
26 28 mod poll;
27 29 mod render;
30 + mod store;
28 31 mod theme;
29 32 mod tls;
30 33 mod value;
@@ -38,7 +41,7 @@
38 41 use tokio::sync::mpsc;
39 42
40 43 use crate::config::{Config, Source};
41 - use crate::model::{FireRequest, Model, Prompt, PromptStep, SourceState};
44 + use crate::model::{FireRequest, Model, Prompt, PromptStep, SourceState, StoreState};
42 45
43 46 /// How often the UI redraws when nothing has arrived.
44 47 ///
@@ -82,6 +85,7 @@
82 85 let runtime = tokio::runtime::Runtime::new().context("starting the async runtime")?;
83 86 let _guard = runtime.enter();
84 87 let updates = poll::spawn_all(&cfg.sources);
88 + let store_updates = store::spawn_all(&cfg.stores);
85 89 // Fired actions report back here. One channel for the whole session; a
86 90 // handful of in-flight actions is the realistic ceiling.
87 91 let (action_tx, action_rx) = mpsc::channel(cfg.sources.len().max(1) * 4);
@@ -91,7 +95,12 @@
91 95 .iter()
92 96 .map(|s| SourceState::new(&s.name, cfg.stale_after(s)).with_actions(s.allow_actions))
93 97 .collect();
94 - let model = Model::new(sources);
98 + let stores = cfg
99 + .stores
100 + .iter()
101 + .map(|s| StoreState::new(&s.name, s.series.clone()))
102 + .collect();
103 + let model = Model::new(sources).with_stores(stores);
95 104
96 105 // The fallible variants: `init()` panics when there is no terminal, which
97 106 // turns "you piped this into less" into a backtrace.
@@ -100,10 +109,13 @@
100 109 &mut terminal,
101 110 model,
102 111 &theme,
103 - updates,
112 + Channels {
113 + updates,
114 + store_updates,
115 + action_tx,
116 + action_rx,
117 + },
104 118 cfg.sources,
105 - action_tx,
106 - action_rx,
107 119 );
108 120 let restored = ratatui::try_restore();
109 121 // Report the run's own failure first; a restore problem is the lesser news
@@ -123,16 +135,24 @@
123 135 Ok(PathBuf::from(home).join(".config/magicmirror/magicmirror.toml"))
124 136 }
125 137
138 + /// Everything the loop talks to the async side through. One struct rather than
139 + /// four parameters: they are created together, they live exactly as long as the
140 + /// loop, and naming the bundle is cheaper than threading each one.
141 + struct Channels {
142 + updates: mpsc::Receiver<poll::Update>,
143 + store_updates: mpsc::Receiver<store::Update>,
144 + action_tx: mpsc::Sender<exec::Outcome>,
145 + action_rx: mpsc::Receiver<exec::Outcome>,
146 + }
147 +
126 148 // The TUI run loop owns model/sources/channels for the app's lifetime.
127 149 #[allow(clippy::needless_pass_by_value)]
128 150 fn run(
129 151 terminal: &mut ratatui::DefaultTerminal,
130 152 mut model: Model,
131 153 theme: &makeover_tui::Theme,
132 - mut updates: mpsc::Receiver<poll::Update>,
154 + mut channels: Channels,
133 155 sources: Vec<Source>,
134 - action_tx: mpsc::Sender<exec::Outcome>,
135 - mut action_rx: mpsc::Receiver<exec::Outcome>,
136 156 ) -> Result<()> {
137 157 loop {
138 158 let now = Utc::now();
@@ -141,10 +161,14 @@
141 161 // Drain everything the pollers have produced without blocking, so a
142 162 // burst of updates costs one redraw rather than one each.
143 163 let mut applied = false;
144 - while let Ok(update) = updates.try_recv() {
164 + while let Ok(update) = channels.updates.try_recv() {
145 165 apply(&mut model, update);
146 166 applied = true;
147 167 }
168 + while let Ok(update) = channels.store_updates.try_recv() {
169 + apply_store(&mut model, update);
170 + applied = true;
171 + }
148 172 // A poll can shorten either list under the cursor, and the lists span
149 173 // every source, so the clamp happens once here rather than inside each
150 174 // source's own update.
@@ -152,7 +176,7 @@
152 176 model.clamp_selection(now);
153 177 }
154 178 // Fired-action outcomes land in the footer.
155 - while let Ok(outcome) = action_rx.try_recv() {
179 + while let Ok(outcome) = channels.action_rx.try_recv() {
156 180 model.message = Some(match outcome.result {
157 181 Ok(code) => format!("{}: ok ({code})", outcome.key),
158 182 Err(reason) => format!("{}: {reason}", outcome.key),
@@ -165,7 +189,7 @@
165 189 {
166 190 let result = handle_key(&mut model, key);
167 191 if let Some(request) = result.fire {
168 - dispatch(&mut model, &sources, request, &action_tx);
192 + dispatch(&mut model, &sources, request, &channels.action_tx);
169 193 }
170 194 if result.flow == Flow::Quit {
171 195 return Ok(());
@@ -214,6 +238,16 @@
214 238 }
215 239 }
216 240
241 + fn apply_store(model: &mut Model, update: store::Update) {
242 + let Some(store) = model.stores.get_mut(update.index) else {
243 + return;
244 + };
245 + match update.result {
246 + Ok(readings) => store.observe(readings, update.at),
247 + Err(error) => store.observe_error(error),
248 + }
249 + }
250 +
217 251 #[derive(Debug, PartialEq)]
218 252 enum Flow {
219 253 Continue,
@@ -8,6 +8,9 @@
8 8 use chrono::{DateTime, TimeDelta, Utc};
9 9 use ops_status::{Action, Event, Node, Payload, Status};
10 10
11 + use crate::config::Series;
12 + use crate::store::Reading;
13 +
11 14 /// One source, as last heard from.
12 15 pub(crate) struct SourceState {
13 16 pub name: String,
@@ -252,6 +255,60 @@
252 255 pub event: &'a Event,
253 256 }
254 257
258 + /// One configured observation store, as last read.
259 + pub(crate) struct StoreState {
260 + pub name: String,
261 + /// The series this store is configured to show, in the order the operator
262 + /// named them. This is where a number's meaning comes from; the store
263 + /// itself cannot say.
264 + pub series: Vec<Series>,
265 + /// The last read that succeeded, kept across a failed one so a producer
266 + /// whose file went away still shows what it last recorded.
267 + pub readings: Vec<Reading>,
268 + /// Why the last read failed, if it did.
269 + pub error: Option<String>,
270 + pub last_ok: Option<DateTime<Utc>>,
271 + }
272 +
273 + impl StoreState {
274 + pub(crate) fn new(name: impl Into<String>, series: Vec<Series>) -> Self {
275 + StoreState {
276 + name: name.into(),
277 + series,
278 + readings: Vec::new(),
279 + error: None,
280 + last_ok: None,
281 + }
282 + }
283 +
284 + pub(crate) fn observe(&mut self, readings: Vec<Reading>, at: DateTime<Utc>) {
285 + self.readings = readings;
286 + self.error = None;
287 + self.last_ok = Some(at);
288 + }
289 +
290 + pub(crate) fn observe_error(&mut self, error: impl Into<String>) {
291 + self.error = Some(error.into());
292 + }
293 + }
294 +
295 + /// One line in the store tab.
296 + pub(crate) enum StoreRow<'a> {
297 + /// The store could not be read. Loud, and above whatever it last said, so
298 + /// old numbers are never mistaken for current ones.
299 + Unavailable { store: &'a str, reason: &'a str },
300 + /// A configured series the store holds no observation of. Shown rather than
301 + /// omitted: a soak target that has never reported is exactly the thing you
302 + /// want to notice, and silence would hide it.
303 + Missing { store: &'a str, spec: &'a Series },
304 + /// A configured series, one row per label set it was recorded under.
305 + Value {
306 + store: &'a str,
307 + spec: &'a Series,
308 + reading: &'a Reading,
309 + },
310 + }
311 +
255 312 /// A modal step between "I want to run this" and the request going out.
256 313 ///
257 314 /// Firing a declared action can move production, so the path to it is explicit
@@ -299,11 +356,14 @@
299 356
300 357 pub(crate) struct Model {
301 358 pub sources: Vec<SourceState>,
359 + pub stores: Vec<StoreState>,
302 360 pub tab: Tab,
303 361 /// Cursor within the live tab, over [`Model::live_rows`].
304 362 pub selected: usize,
305 363 /// How far the logs tab is scrolled, in rows.
306 364 pub logs_scroll: usize,
365 + /// How far the store tab is scrolled, in rows.
366 + pub store_scroll: usize,
307 367 /// Transient message shown in the footer.
308 368 pub message: Option<String>,
309 369 /// The open modal, if any.
@@ -314,14 +374,24 @@
314 374 pub(crate) fn new(sources: Vec<SourceState>) -> Self {
315 375 Model {
316 376 sources,
377 + stores: Vec::new(),
317 378 tab: Tab::Live,
318 379 selected: 0,
319 380 logs_scroll: 0,
381 + store_scroll: 0,
320 382 message: None,
321 383 prompt: None,
322 384 }
323 385 }
324 386
387 + /// The configured stores. Separate from [`Model::new`] because a store is
388 + /// optional and most configs have none, so the common construction should
389 + /// not have to say so.
390 + pub(crate) fn with_stores(mut self, stores: Vec<StoreState>) -> Self {
391 + self.stores = stores;
392 + self
393 + }
394 +
325 395 // -- Actions -----------------------------------------------------------
326 396
327 397 /// Enter on a node in the live tab: open the action picker, or explain why
@@ -551,6 +621,43 @@
551 621 rows
552 622 }
553 623
624 + /// The store tab's lines: each configured store, then each series it was
625 + /// configured to show, in the order the operator named them.
626 + ///
627 + /// A series the store holds but nobody named is not here. That is the
628 + /// ruling, and the reason is that the data cannot say what it means: a
629 + /// number rendered without a label and a unit is a table browser with extra
630 + /// steps. Silence over noise.
631 + pub(crate) fn store_rows(&self) -> Vec<StoreRow<'_>> {
632 + let mut rows = Vec::new();
633 + for store in &self.stores {
634 + if let Some(reason) = &store.error {
635 + rows.push(StoreRow::Unavailable {
636 + store: &store.name,
637 + reason,
638 + });
639 + }
640 + for spec in &store.series {
641 + let mut any = false;
642 + for reading in store.readings.iter().filter(|r| r.series == spec.name) {
643 + any = true;
644 + rows.push(StoreRow::Value {
645 + store: &store.name,
646 + spec,
647 + reading,
648 + });
649 + }
650 + if !any {
651 + rows.push(StoreRow::Missing {
652 + store: &store.name,
653 + spec,
654 + });
655 + }
656 + }
657 + }
658 + rows
659 + }
660 +
554 661 // -- Tabs and cursor -----------------------------------------------------
555 662
556 663 pub(crate) fn tab_index(&self) -> usize {
@@ -585,7 +692,10 @@
585 692 let len = self.log_rows().len();
586 693 self.logs_scroll = clamped(self.logs_scroll, delta, len);
587 694 }
588 - Tab::Store => {}
695 + Tab::Store => {
696 + let len = self.store_rows().len();
697 + self.store_scroll = clamped(self.store_scroll, delta, len);
698 + }
589 699 }
590 700 }
591 701
@@ -600,6 +710,8 @@
600 710 self.selected = self.selected.min(live.saturating_sub(1));
601 711 let logs = self.log_rows().len();
602 712 self.logs_scroll = self.logs_scroll.min(logs.saturating_sub(1));
713 + let store = self.store_rows().len();
714 + self.store_scroll = self.store_scroll.min(store.saturating_sub(1));
603 715 }
604 716 }
605 717
@@ -935,6 +1047,124 @@
935 1047 );
936 1048 }
937 1049
1050 + fn spec(series: &str, label: &str) -> Series {
1051 + Series {
1052 + name: series.into(),
1053 + label: label.into(),
1054 + unit: Some("edges".into()),
1055 + }
1056 + }
1057 +
1058 + fn reading(series: &str, labels: &str, value: f64) -> Reading {
1059 + Reading {
1060 + series: series.into(),
1061 + labels: labels.into(),
1062 + value,
1063 + at: now(),
1064 + }
1065 + }
1066 +
1067 + #[test]
1068 + fn the_store_rows_follow_the_config_and_split_by_label_set() {
1069 + let mut store = StoreState::new(
1070 + "witchbroom",
1071 + vec![
1072 + spec("soak.coverage_edges", "Coverage reached"),
1073 + spec("cache.size_bytes", "Cache size"),
1074 + ],
1075 + );
1076 + store.observe(
1077 + vec![
1078 + reading("soak.coverage_edges", r#"{"repo":"a"}"#, 100.0),
1079 + reading("soak.coverage_edges", r#"{"repo":"b"}"#, 200.0),
1080 + // In the store, never named in config: must not appear.
1081 + reading("cache.hit_rate_pct", "{}", 90.0),
1082 + ],
1083 + now(),
1084 + );
1085 + let m = Model::new(vec![]).with_stores(vec![store]);
1086 + let rows = m.store_rows();
1087 +
1088 + assert_eq!(rows.len(), 3, "two label sets plus the unrecorded series");
1089 + assert!(matches!(
1090 + rows[0],
1091 + StoreRow::Value { spec, .. } if spec.label == "Coverage reached"
1092 + ));
1093 + assert!(matches!(rows[1], StoreRow::Value { .. }));
1094 + // A configured series the store has nothing for is shown, not skipped.
1095 + assert!(matches!(
1096 + rows[2],
1097 + StoreRow::Missing { spec, .. } if spec.label == "Cache size"
1098 + ));
1099 + }
1100 +
1101 + #[test]
1102 + fn a_series_the_config_never_named_is_not_a_row() {
1103 + // The ruling's accepted cost. A fallback that rendered this "just in
1104 + // case" is the table browser arriving by the back door.
1105 + let mut store = StoreState::new("witchbroom", vec![spec("named", "Named")]);
1106 + store.observe(vec![reading("unnamed", "{}", 1.0)], now());
1107 + let m = Model::new(vec![]).with_stores(vec![store]);
1108 + let rows = m.store_rows();
1109 + assert_eq!(rows.len(), 1);
1110 + assert!(matches!(rows[0], StoreRow::Missing { .. }));
1111 + }
1112 +
1113 + #[test]
1114 + fn an_unreadable_store_says_so_above_whatever_it_last_said() {
1115 + let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]);
1116 + store.observe(vec![reading("s", "{}", 41.0)], now());
1117 + store.observe_error("unable to open database file");
1118 + let m = Model::new(vec![]).with_stores(vec![store]);
1119 + let rows = m.store_rows();
1120 +
1121 + assert!(
1122 + matches!(rows[0], StoreRow::Unavailable { reason, .. } if reason.contains("open")),
1123 + "the failure leads, so old numbers are not read as current"
1124 + );
1125 + assert!(
1126 + matches!(rows[1], StoreRow::Value { reading, .. } if (reading.value - 41.0).abs() < f64::EPSILON),
1127 + "the last known values are still there"
1128 + );
1129 + }
1130 +
1131 + #[test]
1132 + fn no_configured_store_is_no_rows_rather_than_an_empty_one() {
1133 + assert!(Model::new(vec![]).store_rows().is_empty());
1134 + }
1135 +
1136 + #[test]
1137 + fn the_store_cursor_cannot_run_off_either_end() {
1138 + let mut store = StoreState::new("witchbroom", vec![spec("a", "A"), spec("b", "B")]);
1139 + store.observe(vec![], now());
1140 + let mut m = Model::new(vec![]).with_stores(vec![store]);
1141 + m.tab = Tab::Store;
1142 + m.move_selection(99, now());
1143 + assert_eq!(m.store_scroll, 1);
1144 + m.move_selection(-99, now());
1145 + assert_eq!(m.store_scroll, 0);
1146 + }
1147 +
1148 + #[test]
1149 + fn a_shrinking_store_pulls_its_cursor_back_in_bounds() {
1150 + let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]);
1151 + store.observe(
1152 + vec![
1153 + reading("s", r#"{"repo":"a"}"#, 1.0),
1154 + reading("s", r#"{"repo":"b"}"#, 2.0),
1155 + reading("s", r#"{"repo":"c"}"#, 3.0),
1156 + ],
1157 + now(),
1158 + );
1159 + let mut m = Model::new(vec![]).with_stores(vec![store]);
1160 + m.tab = Tab::Store;
1161 + m.move_selection(2, now());
1162 + assert_eq!(m.store_scroll, 2);
1163 + m.stores[0].observe(vec![reading("s", r#"{"repo":"a"}"#, 1.0)], now());
1164 + m.clamp_selection(now());
1165 + assert_eq!(m.store_scroll, 0);
1166 + }
1167 +
938 1168 #[test]
939 1169 fn a_source_that_never_answered_contributes_no_log_rows() {
940 1170 let m = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]);
@@ -17,14 +17,14 @@
17 17 use makeover_tui::Theme;
18 18 use makeover_tui::makeover_layout::{Column, Priority, Width};
19 19 use makeover_tui::table::{self, Cell as TableCell, Sizing, TableStyle};
20 - use ops_status::{Method, Node};
20 + use ops_status::{Method, Node, Status};
21 21 use ratatui::Frame;
22 22 use ratatui::layout::{Constraint, Layout, Rect};
23 23 use ratatui::style::{Modifier, Style};
24 24 use ratatui::text::{Line, Span};
25 25 use ratatui::widgets::{Block, Clear, Paragraph, TableState, Tabs};
26 26
27 - use crate::model::{LiveRow, Model, Prompt, SourceState, Tab};
27 + use crate::model::{LiveRow, Model, Prompt, SourceState, StoreRow, Tab};
28 28 use crate::value;
29 29
30 30 pub(crate) fn render(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame) {
@@ -39,7 +39,7 @@
39 39 match model.tab {
40 40 Tab::Live => render_live(model, theme, now, frame, body),
41 41 Tab::Logs => render_logs(model, theme, frame, body),
42 - Tab::Store => render_store(theme, frame, body),
42 + Tab::Store => render_store(model, theme, now, frame, body),
43 43 }
44 44 // A prompt floats over whatever tab is showing: the state behind it keeps
45 45 // updating on every poll, which is the point of not blocking on the modal.
@@ -471,17 +471,165 @@
471 471 // Store
472 472 // ---------------------------------------------------------------------------
473 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 - );
474 + /// The store tab's columns.
475 + ///
476 + /// `series` is the operator's own label for the number, not the store's series
477 + /// name: the store cannot say what a series means, so the config does, and it is
478 + /// the config's word that goes on screen. `labels` is the producer's dimension
479 + /// text, carried through verbatim rather than parsed into columns this crate
480 + /// would have to invent.
481 + const STORE_COLUMNS: [Column<'static>; 5] = [
482 + Column {
483 + name: "store",
484 + width: Width::Content,
485 + priority: Priority::Secondary,
486 + sortable: false,
487 + sorted: None,
488 + },
489 + Column {
490 + name: "series",
491 + width: Width::Content,
492 + priority: Priority::Essential,
493 + sortable: false,
494 + sorted: None,
495 + },
496 + Column {
497 + name: "value",
498 + width: Width::Content,
499 + priority: Priority::Essential,
500 + sortable: false,
501 + sorted: None,
502 + },
503 + Column {
504 + name: "age",
505 + width: Width::Content,
506 + priority: Priority::Secondary,
507 + sortable: false,
508 + sorted: None,
509 + },
510 + Column {
511 + name: "labels",
512 + width: Width::Fill,
513 + priority: Priority::Optional,
514 + sortable: false,
515 + sorted: None,
516 + },
517 + ];
518 +
519 + const STORE_SIZING: Sizing<'static> = Sizing {
520 + lengths: &[
521 + ("store", 10),
522 + ("series", 20),
523 + ("value", 12),
524 + ("age", 8),
525 + ("labels", 10),
526 + ],
527 + fallback: 8,
528 + };
529 +
530 + /// The series a configured store has recorded.
531 + ///
532 + /// The one screen here that renders something other than the `ops-status`
533 + /// contract. What keeps that break narrow is that every row below comes from a
534 + /// series the config named: nothing is discovered, nothing is inferred, and a
535 + /// series nobody named is not on this screen.
536 + fn render_store(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
537 + let store_rows = model.store_rows();
538 + if store_rows.is_empty() {
539 + frame.render_widget(
540 + Paragraph::new(Line::from(Span::styled(
541 + "no [[store]] configured",
542 + muted(theme),
543 + )))
544 + .block(container(theme, " store ")),
545 + area,
546 + );
547 + return;
548 + }
549 +
550 + let rows: Vec<Vec<TableCell>> = store_rows
551 + .iter()
552 + .map(|row| match row {
553 + StoreRow::Unavailable { store, reason } => vec![
554 + TableCell::new("store", (*store).to_string()),
555 + TableCell::new(
556 + "series",
557 + Span::styled(
558 + "unavailable",
559 + Style::default()
560 + .fg(theme.status_danger)
561 + .add_modifier(Modifier::BOLD),
562 + ),
563 + ),
564 + TableCell::new("value", Span::styled(String::new(), muted(theme))),
565 + TableCell::new("age", Span::styled(String::new(), muted(theme))),
566 + TableCell::new(
567 + "labels",
568 + Span::styled(
569 + (*reason).to_string(),
570 + Style::default().fg(theme.status_danger),
571 + ),
572 + ),
573 + ],
574 + StoreRow::Missing { store, spec } => vec![
575 + TableCell::new("store", (*store).to_string()),
576 + TableCell::new("series", spec.label.clone()),
577 + // A named series with nothing behind it is shown, not skipped:
578 + // a soak target that has never reported is the thing worth
579 + // noticing, and omitting it would be indistinguishable from
580 + // never having configured it.
581 + TableCell::new(
582 + "value",
583 + Span::styled(
584 + "no observations",
585 + value::status_style(theme, Status::Unknown),
586 + ),
587 + ),
588 + TableCell::new("age", Span::styled(String::new(), muted(theme))),
589 + TableCell::new("labels", Span::styled(String::new(), muted(theme))),
590 + ],
591 + StoreRow::Value {
592 + store,
593 + spec,
594 + reading,
595 + } => vec![
596 + TableCell::new("store", (*store).to_string()),
597 + TableCell::new("series", spec.label.clone()),
598 + TableCell::new(
599 + "value",
600 + // `quantity` is what the `ops-status` contract's own
601 + // magnitude values render through, so a number on this tab
602 + // reads the same as one on the live tab. The unit is the
603 + // config's word, since the store has none.
604 + Span::styled(
605 + value::quantity(reading.value, spec.unit.as_deref()),
606 + Style::default().fg(theme.content_primary),
607 + ),
608 + ),
609 + TableCell::new(
610 + "age",
611 + Span::styled(
612 + value::duration((now - reading.at).num_seconds()),
613 + muted(theme),
614 + ),
615 + ),
616 + TableCell::new("labels", Span::styled(reading.labels.clone(), muted(theme))),
617 + ],
618 + })
619 + .collect();
620 +
621 + let block = container(theme, " store ");
622 + let inner = block.inner(area);
623 + let table = table::table(
624 + &STORE_COLUMNS,
625 + &rows,
626 + &STORE_SIZING,
627 + &TableStyle::from_theme(theme),
628 + inner.width,
629 + )
630 + .block(block);
631 + let mut state = TableState::default().with_selected(Some(model.store_scroll));
632 + frame.render_stateful_widget(table, area, &mut state);
485 633 }
486 634
487 635 fn field_lines(theme: &Theme, node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> {
@@ -1173,12 +1321,104 @@
1173 1321 }
1174 1322 }
1175 1323
1324 + fn stored(
1325 + series: &[(&str, &str, Option<&str>)],
1326 + readings: Vec<crate::store::Reading>,
1327 + ) -> Model {
1328 + let mut store = crate::model::StoreState::new(
1329 + "witchbroom",
1330 + series
1331 + .iter()
1332 + .map(|(s, label, unit)| crate::config::Series {
1333 + name: (*s).to_string(),
1334 + label: (*label).to_string(),
1335 + unit: unit.map(ToString::to_string),
1336 + })
1337 + .collect(),
1338 + );
1339 + store.observe(readings, now());
1340 + let mut model = Model::new(vec![]).with_stores(vec![store]);
1341 + model.tab = crate::model::Tab::Store;
1342 + model
1343 + }
1344 +
1345 + fn stored_at(
1346 + series: &str,
1347 + labels: &str,
1348 + value: f64,
1349 + at: DateTime<Utc>,
1350 + ) -> crate::store::Reading {
1351 + crate::store::Reading {
1352 + series: series.into(),
1353 + labels: labels.into(),
1354 + value,
1355 + at,
1356 + }
1357 + }
1358 +
1176 1359 #[test]
1177 - fn the_store_tab_renders_rather_than_leaving_the_body_blank() {
1360 + fn the_store_tab_shows_a_configured_series_with_its_label_and_unit() {
1361 + let model = stored(
1362 + &[("soak.coverage_edges", "Coverage reached", Some("edges"))],
1363 + vec![stored_at(
1364 + "soak.coverage_edges",
1365 + r#"{"repo":"mnw-server"}"#,
1366 + 41_200.0,
1367 + now() - TimeDelta::hours(2),
1368 + )],
1369 + );
1370 + let text = joined(&draw(&model, now(), 100, 14));
1371 +
1372 + // The config's label, not the store's series name: the store cannot say
1373 + // what a number means, so what is on screen is what the operator said.
1374 + assert!(text.contains("Coverage reached"), "{text}");
1375 + assert!(!text.contains("soak.coverage_edges"), "{text}");
1376 + assert!(
1377 + text.contains("edges"),
1378 + "the unit comes from config:\n{text}"
1379 + );
1380 + assert!(text.contains("41.2k"), "{text}");
1381 + assert!(text.contains("2h"), "how old the number is:\n{text}");
1382 + // The producer's dimensions, verbatim rather than parsed into columns.
1383 + assert!(text.contains("mnw-server"), "{text}");
1384 + }
1385 +
1386 + #[test]
1387 + fn a_configured_series_with_nothing_behind_it_is_shown_not_skipped() {
1388 + // A soak target that has never reported is the thing worth noticing.
1389 + let model = stored(&[("soak.coverage_edges", "Coverage reached", None)], vec![]);
1390 + let text = joined(&draw(&model, now(), 100, 14));
1391 + assert!(text.contains("Coverage reached"), "{text}");
1392 + assert!(text.contains("no observations"), "{text}");
1393 + }
1394 +
1395 + #[test]
1396 + fn an_unreadable_store_is_visibly_unavailable_rather_than_an_empty_tab() {
1397 + let mut model = stored(
1398 + &[("s", "Something", None)],
1399 + vec![stored_at("s", "{}", 7.0, now())],
1400 + );
1401 + model.stores[0].observe_error("unable to open database file");
1402 + let lines = draw(&model, now(), 100, 14);
1403 + let text = joined(&lines);
1404 +
1405 + assert!(text.contains("unavailable"), "{text}");
1406 + assert!(text.contains("unable to open database file"), "{text}");
1407 + // Above the stale numbers, so they are not read as current.
1408 + let bad = lines
1409 + .iter()
1410 + .position(|l| l.contains("unavailable"))
1411 + .unwrap();
1412 + let old = lines.iter().position(|l| l.contains("Something")).unwrap();
1413 + assert!(bad < old, "{text}");
1414 + }
1415 +
1416 + #[test]
1417 + fn a_store_tab_with_no_store_configured_says_so() {
1178 1418 let mut model = Model::new(vec![source("sando", now(), vec![])]);
1179 1419 model.tab = crate::model::Tab::Store;
1180 1420 let text = joined(&draw(&model, now(), 80, 14));
1181 - assert!(text.contains("store"), "{text}");
1421 + assert!(text.contains("no [[store]] configured"), "{text}");
1182 1422 }
1183 1423
1184 1424 #[test]
@@ -1,0 +1,286 @@
1 + //! Reading a producer's own observation store.
2 + //!
3 + //! This is the one place magicmirror renders something other than the
4 + //! `ops-status` contract, and the break is deliberate and kept narrow (infra
5 + //! `7bafb5dd`). A store is opened **read-only**, queried for exactly the series
6 + //! the config names, and never written to. Nothing here learns what a series
7 + //! means: the config says that, because the data cannot.
8 + //!
9 + //! ## Why the config carries the meaning
10 + //!
11 + //! witchbroom's store is `(series, labels, value, at)` with an `f64`, a shape
12 + //! its own module header defends: a new metric is a new string rather than a
13 + //! migration, and the accepted cost is that the schema cannot say what a number
14 + //! is or what unit it is in. So a viewer has two options. It can render every
15 + //! series it finds, unlabelled, which is a generic table browser; or the
16 + //! operator can name the series worth watching and say what each one is. The
17 + //! second was ruled. A series nobody named is not shown, and that silence is
18 + //! the choice, not an oversight.
19 + //!
20 + //! ## Why it is polled rather than read on draw
21 + //!
22 + //! `render` is a pure function of the model, the theme and the clock, and it is
23 + //! what makes the whole surface testable without a daemon or a database. A
24 + //! query inside it would end that. So a store is read on its own slow interval
25 + //! into the model, exactly as a source is polled, and the tab draws what was
26 + //! last read.
27 +
28 + use std::path::Path;
29 +
30 + use chrono::{DateTime, TimeZone, Utc};
31 + use rusqlite::{Connection, OpenFlags};
32 + use tokio::sync::mpsc;
33 +
34 + use crate::config::{Config, Store};
35 +
36 + /// One store's read, tagged with which store it came from.
37 + pub(crate) struct Update {
38 + pub index: usize,
39 + pub at: DateTime<Utc>,
40 + pub result: Result<Vec<Reading>, String>,
41 + }
42 +
43 + /// The newest observation of one series under one label set.
44 + ///
45 + /// `labels` is the store's own canonical JSON text, carried through verbatim.
46 + /// magicmirror does not parse it: the labels are a producer's dimensions and
47 + /// what they mean is the producer's business, so they are shown as written
48 + /// rather than interpreted into columns this crate would have to invent.
49 + #[derive(Debug, Clone, PartialEq)]
50 + pub(crate) struct Reading {
51 + pub series: String,
52 + pub labels: String,
53 + pub value: f64,
54 + pub at: DateTime<Utc>,
55 + }
56 +
57 + /// Spawn one reading task per configured store. Returns the receiving end.
58 + pub(crate) fn spawn_all(stores: &[Store]) -> mpsc::Receiver<Update> {
59 + let (tx, rx) = mpsc::channel(stores.len().max(1) * 4);
60 + for (index, store) in stores.iter().enumerate() {
61 + tokio::spawn(read_forever(index, store.clone(), tx.clone()));
62 + }
63 + rx
64 + }
65 +
66 + async fn read_forever(index: usize, store: Store, tx: mpsc::Sender<Update>) {
67 + let mut ticker = tokio::time::interval(Config::store_interval(&store));
68 + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
69 + loop {
70 + ticker.tick().await;
71 + // The file is opened per read rather than held. A batch producer
72 + // replaces its database wholesale often enough (a restore, a prune, a
73 + // fresh checkout) that a connection held across hours can end up
74 + // reading a file nobody writes to any more, which looks exactly like a
75 + // producer that stopped.
76 + let store = store.clone();
77 + let result = tokio::task::spawn_blocking(move || read(&store))
78 + .await
79 + .unwrap_or_else(|e| Err(format!("read task: {e}")));
80 + if tx
81 + .send(Update {
82 + index,
83 + at: Utc::now(),
84 + result,
85 + })
86 + .await
87 + .is_err()
88 + {
89 + return; // UI is gone
90 + }
91 + }
92 + }
93 +
94 + /// Read every configured series out of one store.
95 + ///
96 + /// One connection, one query per series, in the order the config names them.
97 + /// The order matters: it is the operator's own ranking of what is worth looking
98 + /// at, and re-sorting it here would replace a decision with an alphabet.
99 + fn read(store: &Store) -> Result<Vec<Reading>, String> {
100 + let conn = open(&store.path)?;
101 + let mut readings = Vec::new();
102 + for series in &store.series {
103 + readings.extend(latest_per_labels(&conn, &series.name).map_err(|e| short(&e))?);
104 + }
105 + Ok(readings)
106 + }
107 +
108 + /// Open a store read-only.
109 + ///
110 + /// `SQLITE_OPEN_READ_ONLY` without `CREATE`, so a path with a typo in it fails
111 + /// as "no such file" rather than quietly creating an empty database that then
112 + /// reads as a producer which has never recorded anything. That distinction is
113 + /// the whole difference between a broken config and a quiet night.
114 + fn open(path: &Path) -> Result<Connection, String> {
115 + Connection::open_with_flags(
116 + path,
117 + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
118 + )
119 + .map_err(|e| short(&e))
120 + }
121 +
122 + /// The newest value of one series, once per distinct label set.
123 + ///
124 + /// A bare `value`/`at` beside `MAX(at)` is SQLite's documented bare-column
125 + /// behaviour: with a single aggregate the row the aggregate came from is the
126 + /// row the bare columns are taken from. It is exactly the "latest per group"
127 + /// this needs and costs no window function.
128 + fn latest_per_labels(conn: &Connection, series: &str) -> rusqlite::Result<Vec<Reading>> {
129 + let mut stmt = conn.prepare(
130 + "SELECT labels, value, MAX(at)
131 + FROM observations
132 + WHERE series = ?1
133 + GROUP BY labels
134 + ORDER BY labels",
135 + )?;
136 + let rows = stmt.query_map([series], |row| {
137 + Ok(Reading {
138 + series: series.to_string(),
139 + labels: row.get(0)?,
140 + value: row.get(1)?,
141 + // Unix seconds, per the producer's schema. A timestamp outside what
142 + // chrono can represent is a corrupt row, not a reason to lose the
143 + // rest of the series, so it reads as the epoch and shows as very
144 + // old rather than taking the read down.
145 + at: Utc
146 + .timestamp_opt(row.get::<_, i64>(2)?, 0)
147 + .single()
148 + .unwrap_or_else(|| Utc.timestamp_nanos(0)),
149 + })
150 + })?;
151 + rows.collect()
152 + }
153 +
154 + /// A one-line reason, because the store tab has one column for it.
155 + fn short(error: &dyn std::error::Error) -> String {
156 + let text = error.to_string();
157 + match text.split_once('\n') {
158 + Some((first, _)) => first.to_string(),
159 + None => text,
160 + }
161 + }
162 +
163 + #[cfg(test)]
164 + mod tests {
165 + use super::*;
166 + use crate::config::Series;
167 +
168 + /// A store file with the producer's schema and the given observations.
169 + fn seeded(rows: &[(&str, &str, f64, i64)]) -> (tempfile::TempDir, std::path::PathBuf) {
170 + let dir = tempfile::tempdir().unwrap();
171 + let path = dir.path().join("observations.db");
172 + let conn = Connection::open(&path).unwrap();
173 + conn.execute_batch(
174 + "CREATE TABLE observations (
175 + id INTEGER PRIMARY KEY,
176 + series TEXT NOT NULL,
177 + labels TEXT NOT NULL,
178 + value REAL NOT NULL,
179 + at INTEGER NOT NULL
180 + );",
181 + )
182 + .unwrap();
183 + for (series, labels, value, at) in rows {
184 + conn.execute(
185 + "INSERT INTO observations (series, labels, value, at) VALUES (?1, ?2, ?3, ?4)",
186 + rusqlite::params![series, labels, value, at],
187 + )
188 + .unwrap();
189 + }
190 + (dir, path)
191 + }
192 +
193 + fn store(path: &Path, series: &[&str]) -> Store {
194 + Store {
195 + name: "witchbroom".into(),
196 + path: path.to_path_buf(),
197 + poll_secs: 60,
198 + series: series
199 + .iter()
200 + .map(|s| Series {
201 + name: (*s).to_string(),
202 + label: format!("label for {s}"),
203 + unit: Some("edges".into()),
204 + })
205 + .collect(),
206 + }
207 + }
208 +
209 + #[test]
210 + fn a_series_reads_its_newest_value_per_label_set() {
211 + let (_dir, path) = seeded(&[
212 + ("soak.coverage_edges", r#"{"repo":"a"}"#, 100.0, 1_000),
213 + ("soak.coverage_edges", r#"{"repo":"a"}"#, 200.0, 2_000),
214 + ("soak.coverage_edges", r#"{"repo":"b"}"#, 50.0, 1_500),
215 + ]);
216 + let readings = read(&store(&path, &["soak.coverage_edges"])).unwrap();
217 + assert_eq!(readings.len(), 2, "one row per label set, not per sample");
218 + assert_eq!(readings[0].labels, r#"{"repo":"a"}"#);
219 + assert!(
220 + (readings[0].value - 200.0).abs() < f64::EPSILON,
221 + "the newest, not the first"
222 + );
223 + assert_eq!(readings[0].at.timestamp(), 2_000);
224 + assert!((readings[1].value - 50.0).abs() < f64::EPSILON);
225 + }
226 +
227 + #[test]
228 + fn a_series_nobody_named_is_not_read() {
229 + // The ruling's accepted cost, asserted: the store holds it and the
230 + // config does not name it, so it does not exist as far as this goes.
231 + let (_dir, path) = seeded(&[
232 + ("soak.coverage_edges", "{}", 1.0, 10),
233 + ("cache.size_bytes", "{}", 999.0, 10),
234 + ]);
235 + let readings = read(&store(&path, &["soak.coverage_edges"])).unwrap();
236 + assert_eq!(readings.len(), 1);
237 + assert_eq!(readings[0].series, "soak.coverage_edges");
238 + }
239 +
240 + #[test]
241 + fn a_named_series_with_no_observations_reads_as_nothing_not_an_error() {
242 + let (_dir, path) = seeded(&[("cache.size_bytes", "{}", 1.0, 10)]);
243 + let readings = read(&store(&path, &["soak.coverage_edges"])).unwrap();
244 + assert!(readings.is_empty());
245 + }
246 +
247 + #[test]
248 + fn a_missing_store_is_a_short_reason_rather_than_a_panic() {
249 + let dir = tempfile::tempdir().unwrap();
250 + let err = read(&store(&dir.path().join("nope.db"), &["x"])).unwrap_err();
251 + assert!(!err.is_empty());
252 + assert!(!err.contains('\n'), "one line for one column: {err}");
253 + }
254 +
255 + #[test]
256 + fn reading_never_creates_the_file_it_could_not_find() {
257 + // A typo in a path must stay a visible error. Creating an empty
258 + // database here would turn it into a producer that reads as having
259 + // recorded nothing, which is a very different night.
260 + let dir = tempfile::tempdir().unwrap();
261 + let path = dir.path().join("nope.db");
262 + assert!(read(&store(&path, &["x"])).is_err());
263 + assert!(!path.exists(), "a read must not create a store");
264 + }
265 +
266 + #[test]
267 + fn a_store_missing_the_producers_table_says_so_rather_than_reading_empty() {
268 + let dir = tempfile::tempdir().unwrap();
269 + let path = dir.path().join("wrong.db");
270 + Connection::open(&path)
271 + .unwrap()
272 + .execute_batch("CREATE TABLE something_else (x INTEGER);")
273 + .unwrap();
274 + let err = read(&store(&path, &["x"])).unwrap_err();
275 + assert!(err.contains("observations"), "{err}");
276 + }
277 +
278 + #[test]
279 + fn the_series_come_back_in_the_order_the_config_names_them() {
280 + // The operator's ranking, not an alphabet.
281 + let (_dir, path) = seeded(&[("zzz", "{}", 1.0, 10), ("aaa", "{}", 2.0, 10)]);
282 + let readings = read(&store(&path, &["zzz", "aaa"])).unwrap();
283 + let names: Vec<&str> = readings.iter().map(|r| r.series.as_str()).collect();
284 + assert_eq!(names, vec!["zzz", "aaa"]);
285 + }
286 + }