max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |