max / makenotwork
9 files changed,
+2572 insertions,
-0 deletions
| @@ -0,0 +1,3062 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "aho-corasick" | |
| 7 | + | version = "1.1.4" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 10 | + | dependencies = [ | |
| 11 | + | "memchr", | |
| 12 | + | ] | |
| 13 | + | ||
| 14 | + | [[package]] | |
| 15 | + | name = "allocator-api2" | |
| 16 | + | version = "0.2.21" | |
| 17 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 19 | + | ||
| 20 | + | [[package]] | |
| 21 | + | name = "android_system_properties" | |
| 22 | + | version = "0.1.5" | |
| 23 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 24 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 25 | + | dependencies = [ | |
| 26 | + | "libc", | |
| 27 | + | ] | |
| 28 | + | ||
| 29 | + | [[package]] | |
| 30 | + | name = "anyhow" | |
| 31 | + | version = "1.0.104" | |
| 32 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 33 | + | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |
| 34 | + | ||
| 35 | + | [[package]] | |
| 36 | + | name = "approx" | |
| 37 | + | version = "0.5.1" | |
| 38 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | + | checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" | |
| 40 | + | dependencies = [ | |
| 41 | + | "num-traits", | |
| 42 | + | ] | |
| 43 | + | ||
| 44 | + | [[package]] | |
| 45 | + | name = "atomic" | |
| 46 | + | version = "0.6.1" | |
| 47 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 48 | + | checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" | |
| 49 | + | dependencies = [ | |
| 50 | + | "bytemuck", | |
| 51 | + | ] | |
| 52 | + | ||
| 53 | + | [[package]] | |
| 54 | + | name = "atomic-waker" | |
| 55 | + | version = "1.1.2" | |
| 56 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 57 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 58 | + | ||
| 59 | + | [[package]] | |
| 60 | + | name = "autocfg" | |
| 61 | + | version = "1.5.1" | |
| 62 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 63 | + | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 64 | + | ||
| 65 | + | [[package]] | |
| 66 | + | name = "aws-lc-rs" | |
| 67 | + | version = "1.17.3" | |
| 68 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 69 | + | checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" | |
| 70 | + | dependencies = [ | |
| 71 | + | "aws-lc-sys", | |
| 72 | + | "zeroize", | |
| 73 | + | ] | |
| 74 | + | ||
| 75 | + | [[package]] | |
| 76 | + | name = "aws-lc-sys" | |
| 77 | + | version = "0.43.0" | |
| 78 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 79 | + | checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" | |
| 80 | + | dependencies = [ | |
| 81 | + | "cc", | |
| 82 | + | "cmake", | |
| 83 | + | "dunce", | |
| 84 | + | "fs_extra", | |
| 85 | + | "pkg-config", | |
| 86 | + | ] | |
| 87 | + | ||
| 88 | + | [[package]] | |
| 89 | + | name = "base64" | |
| 90 | + | version = "0.22.1" | |
| 91 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 92 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 93 | + | ||
| 94 | + | [[package]] | |
| 95 | + | name = "bit-set" | |
| 96 | + | version = "0.5.3" | |
| 97 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 98 | + | checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" | |
| 99 | + | dependencies = [ | |
| 100 | + | "bit-vec", | |
| 101 | + | ] | |
| 102 | + | ||
| 103 | + | [[package]] | |
| 104 | + | name = "bit-vec" | |
| 105 | + | version = "0.6.3" | |
| 106 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 107 | + | checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" | |
| 108 | + | ||
| 109 | + | [[package]] | |
| 110 | + | name = "bitflags" | |
| 111 | + | version = "1.3.2" | |
| 112 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 113 | + | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | |
| 114 | + | ||
| 115 | + | [[package]] | |
| 116 | + | name = "bitflags" | |
| 117 | + | version = "2.13.1" | |
| 118 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 119 | + | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" | |
| 120 | + | ||
| 121 | + | [[package]] | |
| 122 | + | name = "block-buffer" | |
| 123 | + | version = "0.10.4" | |
| 124 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 125 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 126 | + | dependencies = [ | |
| 127 | + | "generic-array", | |
| 128 | + | ] | |
| 129 | + | ||
| 130 | + | [[package]] | |
| 131 | + | name = "bumpalo" | |
| 132 | + | version = "3.20.3" | |
| 133 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 134 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 135 | + | ||
| 136 | + | [[package]] | |
| 137 | + | name = "by_address" | |
| 138 | + | version = "1.2.1" | |
| 139 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 140 | + | checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" | |
| 141 | + | ||
| 142 | + | [[package]] | |
| 143 | + | name = "bytemuck" | |
| 144 | + | version = "1.25.2" | |
| 145 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 146 | + | checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" | |
| 147 | + | ||
| 148 | + | [[package]] | |
| 149 | + | name = "bytes" | |
| 150 | + | version = "1.12.1" | |
| 151 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 152 | + | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | |
| 153 | + | ||
| 154 | + | [[package]] | |
| 155 | + | name = "castaway" | |
| 156 | + | version = "0.2.4" | |
| 157 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 158 | + | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" | |
| 159 | + | dependencies = [ | |
| 160 | + | "rustversion", | |
| 161 | + | ] | |
| 162 | + | ||
| 163 | + | [[package]] | |
| 164 | + | name = "cc" | |
| 165 | + | version = "1.3.0" | |
| 166 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 167 | + | checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" | |
| 168 | + | dependencies = [ | |
| 169 | + | "find-msvc-tools", | |
| 170 | + | "jobserver", | |
| 171 | + | "libc", | |
| 172 | + | "shlex", | |
| 173 | + | ] | |
| 174 | + | ||
| 175 | + | [[package]] | |
| 176 | + | name = "cfg-if" | |
| 177 | + | version = "1.0.4" | |
| 178 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 179 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 180 | + | ||
| 181 | + | [[package]] | |
| 182 | + | name = "cfg_aliases" | |
| 183 | + | version = "0.2.2" | |
| 184 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 185 | + | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" | |
| 186 | + | ||
| 187 | + | [[package]] | |
| 188 | + | name = "chacha20" | |
| 189 | + | version = "0.10.1" | |
| 190 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 191 | + | checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" | |
| 192 | + | dependencies = [ | |
| 193 | + | "cfg-if", | |
| 194 | + | "cpufeatures 0.3.0", | |
| 195 | + | "rand_core 0.10.1", | |
| 196 | + | ] | |
| 197 | + | ||
| 198 | + | [[package]] | |
| 199 | + | name = "chrono" | |
| 200 | + | version = "0.4.45" | |
| 201 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 202 | + | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | |
| 203 | + | dependencies = [ | |
| 204 | + | "iana-time-zone", | |
| 205 | + | "js-sys", | |
| 206 | + | "num-traits", | |
| 207 | + | "serde", | |
| 208 | + | "wasm-bindgen", | |
| 209 | + | "windows-link", | |
| 210 | + | ] | |
| 211 | + | ||
| 212 | + | [[package]] | |
| 213 | + | name = "cmake" | |
| 214 | + | version = "0.1.58" | |
| 215 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 216 | + | checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" | |
| 217 | + | dependencies = [ | |
| 218 | + | "cc", | |
| 219 | + | ] | |
| 220 | + | ||
| 221 | + | [[package]] | |
| 222 | + | name = "combine" | |
| 223 | + | version = "4.6.7" | |
| 224 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 225 | + | checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" | |
| 226 | + | dependencies = [ | |
| 227 | + | "bytes", | |
| 228 | + | "memchr", | |
| 229 | + | ] | |
| 230 | + | ||
| 231 | + | [[package]] | |
| 232 | + | name = "compact_str" | |
| 233 | + | version = "0.9.1" | |
| 234 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 235 | + | checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" | |
| 236 | + | dependencies = [ | |
| 237 | + | "castaway", | |
| 238 | + | "cfg-if", | |
| 239 | + | "itoa", | |
| 240 | + | "rustversion", | |
| 241 | + | "ryu", | |
| 242 | + | "static_assertions", | |
| 243 | + | ] | |
| 244 | + | ||
| 245 | + | [[package]] | |
| 246 | + | name = "convert_case" | |
| 247 | + | version = "0.10.0" | |
| 248 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 249 | + | checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" | |
| 250 | + | dependencies = [ | |
| 251 | + | "unicode-segmentation", | |
| 252 | + | ] | |
| 253 | + | ||
| 254 | + | [[package]] | |
| 255 | + | name = "core-foundation" | |
| 256 | + | version = "0.10.1" | |
| 257 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 258 | + | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | |
| 259 | + | dependencies = [ | |
| 260 | + | "core-foundation-sys", | |
| 261 | + | "libc", | |
| 262 | + | ] | |
| 263 | + | ||
| 264 | + | [[package]] | |
| 265 | + | name = "core-foundation-sys" | |
| 266 | + | version = "0.8.7" | |
| 267 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 268 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 269 | + | ||
| 270 | + | [[package]] | |
| 271 | + | name = "cpufeatures" | |
| 272 | + | version = "0.2.17" | |
| 273 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 274 | + | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 275 | + | dependencies = [ | |
| 276 | + | "libc", | |
| 277 | + | ] | |
| 278 | + | ||
| 279 | + | [[package]] | |
| 280 | + | name = "cpufeatures" | |
| 281 | + | version = "0.3.0" | |
| 282 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 283 | + | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" | |
| 284 | + | dependencies = [ | |
| 285 | + | "libc", | |
| 286 | + | ] | |
| 287 | + | ||
| 288 | + | [[package]] | |
| 289 | + | name = "critical-section" | |
| 290 | + | version = "1.2.0" | |
| 291 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 292 | + | checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" | |
| 293 | + | ||
| 294 | + | [[package]] | |
| 295 | + | name = "crossterm" | |
| 296 | + | version = "0.29.0" | |
| 297 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 298 | + | checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" | |
| 299 | + | dependencies = [ | |
| 300 | + | "bitflags 2.13.1", | |
| 301 | + | "crossterm_winapi", | |
| 302 | + | "derive_more", | |
| 303 | + | "document-features", | |
| 304 | + | "mio", | |
| 305 | + | "parking_lot", | |
| 306 | + | "rustix", | |
| 307 | + | "signal-hook", | |
| 308 | + | "signal-hook-mio", | |
| 309 | + | "winapi", | |
| 310 | + | ] | |
| 311 | + | ||
| 312 | + | [[package]] | |
| 313 | + | name = "crossterm_winapi" | |
| 314 | + | version = "0.9.1" | |
| 315 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 316 | + | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" | |
| 317 | + | dependencies = [ | |
| 318 | + | "winapi", | |
| 319 | + | ] | |
| 320 | + | ||
| 321 | + | [[package]] | |
| 322 | + | name = "crypto-common" | |
| 323 | + | version = "0.1.7" | |
| 324 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 325 | + | checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | |
| 326 | + | dependencies = [ | |
| 327 | + | "generic-array", | |
| 328 | + | "typenum", | |
| 329 | + | ] | |
| 330 | + | ||
| 331 | + | [[package]] | |
| 332 | + | name = "csscolorparser" | |
| 333 | + | version = "0.6.2" | |
| 334 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 335 | + | checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" | |
| 336 | + | dependencies = [ | |
| 337 | + | "lab", | |
| 338 | + | "phf", | |
| 339 | + | ] | |
| 340 | + | ||
| 341 | + | [[package]] | |
| 342 | + | name = "darling" | |
| 343 | + | version = "0.23.0" | |
| 344 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 345 | + | checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" | |
| 346 | + | dependencies = [ | |
| 347 | + | "darling_core", | |
| 348 | + | "darling_macro", | |
| 349 | + | ] | |
| 350 | + | ||
| 351 | + | [[package]] | |
| 352 | + | name = "darling_core" | |
| 353 | + | version = "0.23.0" | |
| 354 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 355 | + | checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" | |
| 356 | + | dependencies = [ | |
| 357 | + | "ident_case", | |
| 358 | + | "proc-macro2", | |
| 359 | + | "quote", | |
| 360 | + | "strsim", | |
| 361 | + | "syn 2.0.119", | |
| 362 | + | ] | |
| 363 | + | ||
| 364 | + | [[package]] | |
| 365 | + | name = "darling_macro" | |
| 366 | + | version = "0.23.0" | |
| 367 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 368 | + | checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" | |
| 369 | + | dependencies = [ | |
| 370 | + | "darling_core", | |
| 371 | + | "quote", | |
| 372 | + | "syn 2.0.119", | |
| 373 | + | ] | |
| 374 | + | ||
| 375 | + | [[package]] | |
| 376 | + | name = "deltae" | |
| 377 | + | version = "0.3.2" | |
| 378 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 379 | + | checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" | |
| 380 | + | ||
| 381 | + | [[package]] | |
| 382 | + | name = "deranged" | |
| 383 | + | version = "0.5.8" | |
| 384 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 385 | + | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" | |
| 386 | + | ||
| 387 | + | [[package]] | |
| 388 | + | name = "derive_more" | |
| 389 | + | version = "2.1.1" | |
| 390 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 391 | + | checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" | |
| 392 | + | dependencies = [ | |
| 393 | + | "derive_more-impl", | |
| 394 | + | ] | |
| 395 | + | ||
| 396 | + | [[package]] | |
| 397 | + | name = "derive_more-impl" | |
| 398 | + | version = "2.1.1" | |
| 399 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 400 | + | checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" | |
| 401 | + | dependencies = [ | |
| 402 | + | "convert_case", | |
| 403 | + | "proc-macro2", | |
| 404 | + | "quote", | |
| 405 | + | "rustc_version", | |
| 406 | + | "syn 2.0.119", | |
| 407 | + | ] | |
| 408 | + | ||
| 409 | + | [[package]] | |
| 410 | + | name = "digest" | |
| 411 | + | version = "0.10.7" | |
| 412 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 413 | + | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 414 | + | dependencies = [ | |
| 415 | + | "block-buffer", | |
| 416 | + | "crypto-common", | |
| 417 | + | ] | |
| 418 | + | ||
| 419 | + | [[package]] | |
| 420 | + | name = "displaydoc" | |
| 421 | + | version = "0.2.6" | |
| 422 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 423 | + | checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" | |
| 424 | + | dependencies = [ | |
| 425 | + | "proc-macro2", | |
| 426 | + | "quote", | |
| 427 | + | "syn 2.0.119", | |
| 428 | + | ] | |
| 429 | + | ||
| 430 | + | [[package]] | |
| 431 | + | name = "document-features" | |
| 432 | + | version = "0.2.12" | |
| 433 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 434 | + | checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" | |
| 435 | + | dependencies = [ | |
| 436 | + | "litrs", | |
| 437 | + | ] | |
| 438 | + | ||
| 439 | + | [[package]] | |
| 440 | + | name = "dunce" | |
| 441 | + | version = "1.0.5" | |
| 442 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 443 | + | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" | |
| 444 | + | ||
| 445 | + | [[package]] | |
| 446 | + | name = "either" | |
| 447 | + | version = "1.16.0" | |
| 448 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 449 | + | checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" | |
| 450 | + | ||
| 451 | + | [[package]] | |
| 452 | + | name = "equivalent" | |
| 453 | + | version = "1.0.2" | |
| 454 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 455 | + | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | |
| 456 | + | ||
| 457 | + | [[package]] | |
| 458 | + | name = "errno" | |
| 459 | + | version = "0.3.14" | |
| 460 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 461 | + | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 462 | + | dependencies = [ | |
| 463 | + | "libc", | |
| 464 | + | "windows-sys 0.61.2", | |
| 465 | + | ] | |
| 466 | + | ||
| 467 | + | [[package]] | |
| 468 | + | name = "euclid" | |
| 469 | + | version = "0.22.14" | |
| 470 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 471 | + | checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" | |
| 472 | + | dependencies = [ | |
| 473 | + | "num-traits", | |
| 474 | + | ] | |
| 475 | + | ||
| 476 | + | [[package]] | |
| 477 | + | name = "fancy-regex" | |
| 478 | + | version = "0.11.0" | |
| 479 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 480 | + | checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" | |
| 481 | + | dependencies = [ | |
| 482 | + | "bit-set", | |
| 483 | + | "regex", | |
| 484 | + | ] | |
| 485 | + | ||
| 486 | + | [[package]] | |
| 487 | + | name = "fast-srgb8" | |
| 488 | + | version = "1.0.0" | |
| 489 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 490 | + | checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" | |
| 491 | + | ||
| 492 | + | [[package]] | |
| 493 | + | name = "fastrand" | |
| 494 | + | version = "2.5.0" | |
| 495 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 496 | + | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" | |
| 497 | + | ||
| 498 | + | [[package]] | |
| 499 | + | name = "filedescriptor" | |
| 500 | + | version = "0.8.3" |
Lines truncated
| @@ -0,0 +1,26 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "ops-viewer" | |
| 3 | + | version = "0.1.0" | |
| 4 | + | edition = "2024" | |
| 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." | |
| 7 | + | ||
| 8 | + | [[bin]] | |
| 9 | + | name = "ops-viewer" | |
| 10 | + | path = "src/main.rs" | |
| 11 | + | ||
| 12 | + | [dependencies] | |
| 13 | + | ops-status = { path = "../shared/ops-status" } | |
| 14 | + | # crossterm comes from ratatui's own re-export (`ratatui::crossterm`, 0.29) so | |
| 15 | + | # the two cannot drift into a version mismatch. | |
| 16 | + | ratatui = "0.30.2" | |
| 17 | + | tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] } | |
| 18 | + | reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } | |
| 19 | + | serde = { version = "1.0.228", features = ["derive"] } | |
| 20 | + | serde_json = "1" | |
| 21 | + | toml = "1.1" | |
| 22 | + | chrono = { version = "0.4", features = ["serde"] } | |
| 23 | + | anyhow = "1.0.102" | |
| 24 | + | ||
| 25 | + | [dev-dependencies] | |
| 26 | + | tempfile = "3.20" |
| @@ -0,0 +1,39 @@ | |||
| 1 | + | # ops-viewer: which daemons to watch. | |
| 2 | + | # | |
| 3 | + | # Install at ~/.config/ops-viewer/viewer.toml, or pass a path as the first | |
| 4 | + | # argument, or set $OPS_VIEWER_CONFIG. | |
| 5 | + | # | |
| 6 | + | # Adding a service is an edit to this file and nothing else. The viewer knows | |
| 7 | + | # nothing about tiers, gates, apps or targets; it renders whatever the source | |
| 8 | + | # emits at GET /status.json. A new daemon gets a UI by emitting the payload. | |
| 9 | + | ||
| 10 | + | # Age past which a source's answer stops counting as current, for any source | |
| 11 | + | # that does not set its own. A source that answers with something hours old is | |
| 12 | + | # reported as degraded no matter how green its contents: the failure this | |
| 13 | + | # whole surface exists to catch is a check that kept saying "ok" about a | |
| 14 | + | # snapshot nobody had refreshed in forty days. | |
| 15 | + | stale_after_secs = 60 | |
| 16 | + | ||
| 17 | + | [[source]] | |
| 18 | + | name = "sando" | |
| 19 | + | url = "http://fw13:8080" | |
| 20 | + | # Sando gates its reads, so a token is required. The token is NAMED here, never | |
| 21 | + | # pasted: this file describes topology and has every reason to be readable, | |
| 22 | + | # while sandod already takes the same value from its environment. | |
| 23 | + | token_env = "SANDO_API_TOKEN" | |
| 24 | + | poll_secs = 5 | |
| 25 | + | ||
| 26 | + | [[source]] | |
| 27 | + | name = "bento" | |
| 28 | + | url = "http://fw13:8090" | |
| 29 | + | # Bento leaves its reads open, so no token is needed. Set token_env anyway if | |
| 30 | + | # that ever changes; an unset variable is treated as no token. | |
| 31 | + | poll_secs = 10 | |
| 32 | + | ||
| 33 | + | # PoM polls its own targets on a slower cycle, so hold it to a slower staleness | |
| 34 | + | # limit rather than flagging it degraded between its own checks. | |
| 35 | + | # [[source]] | |
| 36 | + | # name = "pom" | |
| 37 | + | # url = "http://pom:9000" | |
| 38 | + | # poll_secs = 30 | |
| 39 | + | # stale_after_secs = 600 |
| @@ -0,0 +1,247 @@ | |||
| 1 | + | //! What the viewer is pointed at. | |
| 2 | + | //! | |
| 3 | + | //! Adding a service is a config edit and nothing else — no code change, no new | |
| 4 | + | //! tab type, no widget. That property is what makes the viewer modular rather | |
| 5 | + | //! than merely tabbed, and it is worth defending: the moment a source needs a | |
| 6 | + | //! special case here, the shell has started learning domain vocabulary. | |
| 7 | + | ||
| 8 | + | use std::path::Path; | |
| 9 | + | use std::time::Duration; | |
| 10 | + | ||
| 11 | + | use anyhow::{Context, Result}; | |
| 12 | + | use serde::Deserialize; | |
| 13 | + | ||
| 14 | + | /// Default poll interval. Matches what the Sando TUI already did. | |
| 15 | + | const DEFAULT_POLL_SECS: u64 = 5; | |
| 16 | + | ||
| 17 | + | /// Default age past which a source's answer stops counting as current. | |
| 18 | + | /// | |
| 19 | + | /// Deliberately a small multiple of the poll interval rather than something | |
| 20 | + | /// generous: the whole point is that a source which quietly stopped updating | |
| 21 | + | /// looks different from one that is fine. | |
| 22 | + | const DEFAULT_STALE_SECS: u64 = 60; | |
| 23 | + | ||
| 24 | + | #[derive(Debug, Clone, Deserialize)] | |
| 25 | + | pub struct Config { | |
| 26 | + | /// Fallback for any source that does not set its own. | |
| 27 | + | #[serde(default = "default_stale")] | |
| 28 | + | pub stale_after_secs: u64, | |
| 29 | + | #[serde(default, rename = "source")] | |
| 30 | + | pub sources: Vec<Source>, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | #[derive(Debug, Clone, Deserialize)] | |
| 34 | + | pub struct Source { | |
| 35 | + | /// Tab label, and the `source` name the payload should carry. | |
| 36 | + | pub name: String, | |
| 37 | + | /// Base URL. `/status.json` is appended. | |
| 38 | + | pub url: String, | |
| 39 | + | /// Name of the environment variable holding this source's bearer token. | |
| 40 | + | /// | |
| 41 | + | /// The token is named, never inlined: this file describes topology and has | |
| 42 | + | /// every reason to be readable, while the daemons already take their tokens | |
| 43 | + | /// from the environment (`SANDO_API_TOKEN`, `BENTO_API_TOKEN`). A config | |
| 44 | + | /// format that invites pasting a prod token into a file is one that ends | |
| 45 | + | /// with a prod token in a file. | |
| 46 | + | #[serde(default)] | |
| 47 | + | pub token_env: Option<String>, | |
| 48 | + | #[serde(default = "default_poll")] | |
| 49 | + | pub poll_secs: u64, | |
| 50 | + | #[serde(default)] | |
| 51 | + | pub stale_after_secs: Option<u64>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | fn default_poll() -> u64 { | |
| 55 | + | DEFAULT_POLL_SECS | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | fn default_stale() -> u64 { | |
| 59 | + | DEFAULT_STALE_SECS | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | impl Source { | |
| 63 | + | pub fn status_url(&self) -> String { | |
| 64 | + | format!("{}/status.json", self.url.trim_end_matches('/')) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | pub fn poll_interval(&self) -> Duration { | |
| 68 | + | Duration::from_secs(self.poll_secs.max(1)) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Resolve the bearer token from the environment, if one is named. | |
| 72 | + | pub fn token(&self) -> Option<String> { | |
| 73 | + | self.token_env | |
| 74 | + | .as_deref() | |
| 75 | + | .and_then(|name| std::env::var(name).ok()) | |
| 76 | + | .filter(|t| !t.is_empty()) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | impl Config { | |
| 81 | + | pub fn load(path: &Path) -> Result<Self> { | |
| 82 | + | let raw = std::fs::read_to_string(path) | |
| 83 | + | .with_context(|| format!("reading viewer config at {}", path.display()))?; | |
| 84 | + | let cfg: Config = toml::from_str(&raw) | |
| 85 | + | .with_context(|| format!("parsing viewer config at {}", path.display()))?; | |
| 86 | + | cfg.validate()?; | |
| 87 | + | Ok(cfg) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | fn validate(&self) -> Result<()> { | |
| 91 | + | anyhow::ensure!( | |
| 92 | + | !self.sources.is_empty(), | |
| 93 | + | "no [[source]] entries: the viewer would have nothing to show" | |
| 94 | + | ); | |
| 95 | + | let mut seen = std::collections::HashSet::new(); | |
| 96 | + | for source in &self.sources { | |
| 97 | + | anyhow::ensure!( | |
| 98 | + | seen.insert(source.name.as_str()), | |
| 99 | + | "duplicate source name {:?}: tabs would be ambiguous", | |
| 100 | + | source.name | |
| 101 | + | ); | |
| 102 | + | anyhow::ensure!( | |
| 103 | + | source.url.starts_with("http://") || source.url.starts_with("https://"), | |
| 104 | + | "source {:?} url must start with http:// or https://", | |
| 105 | + | source.name | |
| 106 | + | ); | |
| 107 | + | } | |
| 108 | + | Ok(()) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Staleness limit for one source: its own, else the global default. | |
| 112 | + | pub fn stale_after(&self, source: &Source) -> chrono::TimeDelta { | |
| 113 | + | let secs = source.stale_after_secs.unwrap_or(self.stale_after_secs); | |
| 114 | + | chrono::TimeDelta::seconds(secs as i64) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | #[cfg(test)] | |
| 119 | + | mod tests { | |
| 120 | + | use super::*; | |
| 121 | + | ||
| 122 | + | fn write(body: &str) -> (tempfile::TempDir, std::path::PathBuf) { | |
| 123 | + | let dir = tempfile::tempdir().unwrap(); | |
| 124 | + | let path = dir.path().join("viewer.toml"); | |
| 125 | + | std::fs::write(&path, body).unwrap(); | |
| 126 | + | (dir, path) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[test] | |
| 130 | + | fn a_minimal_source_gets_sensible_defaults() { | |
| 131 | + | let (_dir, path) = write( | |
| 132 | + | r#" | |
| 133 | + | [[source]] | |
| 134 | + | name = "sando" | |
| 135 | + | url = "http://fw13:8080" | |
| 136 | + | "#, | |
| 137 | + | ); | |
| 138 | + | let cfg = Config::load(&path).unwrap(); | |
| 139 | + | assert_eq!(cfg.sources[0].status_url(), "http://fw13:8080/status.json"); | |
| 140 | + | assert_eq!(cfg.sources[0].poll_interval().as_secs(), DEFAULT_POLL_SECS); | |
| 141 | + | assert_eq!( | |
| 142 | + | cfg.stale_after(&cfg.sources[0]), | |
| 143 | + | chrono::TimeDelta::seconds(DEFAULT_STALE_SECS as i64) | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[test] | |
| 148 | + | fn a_trailing_slash_does_not_double_up() { | |
| 149 | + | let (_dir, path) = write( | |
| 150 | + | r#" | |
| 151 | + | [[source]] | |
| 152 | + | name = "bento" | |
| 153 | + | url = "http://fw13:8090/" | |
| 154 | + | "#, | |
| 155 | + | ); | |
| 156 | + | let cfg = Config::load(&path).unwrap(); | |
| 157 | + | assert_eq!(cfg.sources[0].status_url(), "http://fw13:8090/status.json"); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn a_per_source_staleness_overrides_the_default() { | |
| 162 | + | let (_dir, path) = write( | |
| 163 | + | r#" | |
| 164 | + | stale_after_secs = 60 | |
| 165 | + | ||
| 166 | + | [[source]] | |
| 167 | + | name = "sando" | |
| 168 | + | url = "http://fw13:8080" | |
| 169 | + | ||
| 170 | + | [[source]] | |
| 171 | + | name = "pom" | |
| 172 | + | url = "http://pom:9000" | |
| 173 | + | stale_after_secs = 600 | |
| 174 | + | "#, | |
| 175 | + | ); | |
| 176 | + | let cfg = Config::load(&path).unwrap(); | |
| 177 | + | assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 60); | |
| 178 | + | assert_eq!(cfg.stale_after(&cfg.sources[1]).num_seconds(), 600); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | #[test] | |
| 182 | + | fn an_empty_config_is_rejected_rather_than_showing_an_empty_screen() { | |
| 183 | + | let (_dir, path) = write("stale_after_secs = 60\n"); | |
| 184 | + | assert!( | |
| 185 | + | Config::load(&path) | |
| 186 | + | .unwrap_err() | |
| 187 | + | .to_string() | |
| 188 | + | .contains("no [[source]]") | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | #[test] | |
| 193 | + | fn duplicate_source_names_are_rejected() { | |
| 194 | + | let (_dir, path) = write( | |
| 195 | + | r#" | |
| 196 | + | [[source]] | |
| 197 | + | name = "sando" | |
| 198 | + | url = "http://a" | |
| 199 | + | [[source]] | |
| 200 | + | name = "sando" | |
| 201 | + | url = "http://b" | |
| 202 | + | "#, | |
| 203 | + | ); | |
| 204 | + | assert!( | |
| 205 | + | Config::load(&path) | |
| 206 | + | .unwrap_err() | |
| 207 | + | .to_string() | |
| 208 | + | .contains("duplicate") | |
| 209 | + | ); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn a_url_without_a_scheme_is_rejected() { | |
| 214 | + | let (_dir, path) = write( | |
| 215 | + | r#" | |
| 216 | + | [[source]] | |
| 217 | + | name = "sando" | |
| 218 | + | url = "fw13:8080" | |
| 219 | + | "#, | |
| 220 | + | ); | |
| 221 | + | assert!( | |
| 222 | + | Config::load(&path) | |
| 223 | + | .unwrap_err() | |
| 224 | + | .to_string() | |
| 225 | + | .contains("http://") | |
| 226 | + | ); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[test] | |
| 230 | + | fn a_token_is_read_from_the_environment_never_the_file() { | |
| 231 | + | let (_dir, path) = write( | |
| 232 | + | r#" | |
| 233 | + | [[source]] | |
| 234 | + | name = "sando" | |
| 235 | + | url = "http://fw13:8080" | |
| 236 | + | token_env = "OPS_VIEWER_TEST_TOKEN" | |
| 237 | + | "#, | |
| 238 | + | ); | |
| 239 | + | let cfg = Config::load(&path).unwrap(); | |
| 240 | + | // Unset -> no token rather than an empty string masquerading as one. | |
| 241 | + | unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") }; | |
| 242 | + | assert_eq!(cfg.sources[0].token(), None); | |
| 243 | + | unsafe { std::env::set_var("OPS_VIEWER_TEST_TOKEN", "s3cr3t") }; | |
| 244 | + | assert_eq!(cfg.sources[0].token().as_deref(), Some("s3cr3t")); | |
| 245 | + | unsafe { std::env::remove_var("OPS_VIEWER_TEST_TOKEN") }; | |
| 246 | + | } | |
| 247 | + | } |
| @@ -0,0 +1,252 @@ | |||
| 1 | + | //! One terminal surface over every operator daemon that emits an `ops-status` | |
| 2 | + | //! payload. | |
| 3 | + | //! | |
| 4 | + | //! Design + rationale: maintainer wiki. | |
| 5 | + | //! <!-- wiki: release-viewer-overview --> | |
| 6 | + | //! | |
| 7 | + | //! Tabs, one per hooked-in source, plus a rollup that shows every source at | |
| 8 | + | //! once, worst first. The rollup is the reason the product exists: without it | |
| 9 | + | //! this is N tabs you still have to visit one at a time, which is the situation | |
| 10 | + | //! it replaces. | |
| 11 | + | //! | |
| 12 | + | //! The viewer knows nothing about tiers, gates, apps, or targets. It renders | |
| 13 | + | //! the shared contract in `ops-status` and nothing else, which is what lets a | |
| 14 | + | //! new daemon arrive with a UI already written. | |
| 15 | + | //! | |
| 16 | + | //! # Boundary | |
| 17 | + | //! | |
| 18 | + | //! This displays; it does not interrupt. PoM pushing failures into GoingsOn is | |
| 19 | + | //! what wakes you up. This is what you look at once you are already awake. A | |
| 20 | + | //! surface that tries to be both becomes one nobody watches. | |
| 21 | + | ||
| 22 | + | mod config; | |
| 23 | + | mod model; | |
| 24 | + | mod poll; | |
| 25 | + | mod render; | |
| 26 | + | mod value; | |
| 27 | + | ||
| 28 | + | use std::path::PathBuf; | |
| 29 | + | use std::time::Duration; | |
| 30 | + | ||
| 31 | + | use anyhow::{Context, Result}; | |
| 32 | + | use chrono::Utc; | |
| 33 | + | use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; | |
| 34 | + | ||
| 35 | + | use crate::config::Config; | |
| 36 | + | use crate::model::{Model, SourceState}; | |
| 37 | + | ||
| 38 | + | /// How often the UI redraws when nothing has arrived. | |
| 39 | + | /// | |
| 40 | + | /// Relative timestamps ("3m ago") go stale on their own, so the screen has to | |
| 41 | + | /// repaint on a clock even when no source has said anything new. | |
| 42 | + | const TICK: Duration = Duration::from_millis(500); | |
| 43 | + | ||
| 44 | + | fn main() -> Result<()> { | |
| 45 | + | let path = config_path()?; | |
| 46 | + | let cfg = Config::load(&path)?; | |
| 47 | + | ||
| 48 | + | let runtime = tokio::runtime::Runtime::new().context("starting the async runtime")?; | |
| 49 | + | let _guard = runtime.enter(); | |
| 50 | + | let updates = poll::spawn_all(&cfg.sources); | |
| 51 | + | ||
| 52 | + | let sources = cfg | |
| 53 | + | .sources | |
| 54 | + | .iter() | |
| 55 | + | .map(|s| SourceState::new(&s.name, cfg.stale_after(s))) | |
| 56 | + | .collect(); | |
| 57 | + | let model = Model::new(sources); | |
| 58 | + | ||
| 59 | + | // The fallible variants: `init()` panics when there is no terminal, which | |
| 60 | + | // turns "you piped this into less" into a backtrace. | |
| 61 | + | let mut terminal = ratatui::try_init().context("this needs a terminal (no TTY attached)")?; | |
| 62 | + | let outcome = run(&mut terminal, model, updates); | |
| 63 | + | let restored = ratatui::try_restore(); | |
| 64 | + | // Report the run's own failure first; a restore problem is the lesser news | |
| 65 | + | // and must not mask why the app actually stopped. | |
| 66 | + | outcome.and(restored.context("restoring the terminal")) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// An explicit argument, `$OPS_VIEWER_CONFIG`, or the XDG default. | |
| 70 | + | fn config_path() -> Result<PathBuf> { | |
| 71 | + | if let Some(arg) = std::env::args().nth(1) { | |
| 72 | + | return Ok(PathBuf::from(arg)); | |
| 73 | + | } | |
| 74 | + | if let Ok(path) = std::env::var("OPS_VIEWER_CONFIG") { | |
| 75 | + | return Ok(PathBuf::from(path)); | |
| 76 | + | } | |
| 77 | + | let home = std::env::var("HOME").context("HOME is unset and no config path was given")?; | |
| 78 | + | Ok(PathBuf::from(home).join(".config/ops-viewer/viewer.toml")) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn run( | |
| 82 | + | terminal: &mut ratatui::DefaultTerminal, | |
| 83 | + | mut model: Model, | |
| 84 | + | mut updates: tokio::sync::mpsc::Receiver<poll::Update>, | |
| 85 | + | ) -> Result<()> { | |
| 86 | + | loop { | |
| 87 | + | let now = Utc::now(); | |
| 88 | + | terminal.draw(|frame| render::render(&model, now, frame))?; | |
| 89 | + | ||
| 90 | + | // Drain everything the pollers have produced without blocking, so a | |
| 91 | + | // burst of updates costs one redraw rather than one each. | |
| 92 | + | while let Ok(update) = updates.try_recv() { | |
| 93 | + | apply(&mut model, update); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | if event::poll(TICK)? | |
| 97 | + | && let Event::Key(key) = event::read()? | |
| 98 | + | && key.kind == KeyEventKind::Press | |
| 99 | + | && handle_key(&mut model, key) == Flow::Quit | |
| 100 | + | { | |
| 101 | + | return Ok(()); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | fn apply(model: &mut Model, update: poll::Update) { | |
| 107 | + | let Some(source) = model.sources.get_mut(update.index) else { | |
| 108 | + | return; | |
| 109 | + | }; | |
| 110 | + | match update.result { | |
| 111 | + | Ok(payload) => source.observe(payload, update.at), | |
| 112 | + | Err(error) => source.observe_error(error), | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[derive(Debug, PartialEq)] | |
| 117 | + | enum Flow { | |
| 118 | + | Continue, | |
| 119 | + | Quit, | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | fn handle_key(model: &mut Model, key: KeyEvent) -> Flow { | |
| 123 | + | let now = Utc::now(); | |
| 124 | + | model.message = None; | |
| 125 | + | ||
| 126 | + | match key.code { | |
| 127 | + | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Flow::Quit, | |
| 128 | + | KeyCode::Char('q') | KeyCode::Esc => return Flow::Quit, | |
| 129 | + | KeyCode::Tab | KeyCode::Right => model.next_tab(), | |
| 130 | + | KeyCode::BackTab | KeyCode::Left => model.prev_tab(), | |
| 131 | + | KeyCode::Down | KeyCode::Char('j') => model.move_selection(1, now), | |
| 132 | + | KeyCode::Up | KeyCode::Char('k') => model.move_selection(-1, now), | |
| 133 | + | KeyCode::Enter => model.open_selected(now), | |
| 134 | + | KeyCode::Char(c) if c.is_ascii_digit() => { | |
| 135 | + | model.select_tab(c.to_digit(10).unwrap_or(0) as usize); | |
| 136 | + | } | |
| 137 | + | _ => {} | |
| 138 | + | } | |
| 139 | + | Flow::Continue | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[cfg(test)] | |
| 143 | + | mod tests { | |
| 144 | + | use super::*; | |
| 145 | + | use chrono::TimeDelta; | |
| 146 | + | use ops_status::{Payload, Status}; | |
| 147 | + | ||
| 148 | + | fn key(code: KeyCode) -> KeyEvent { | |
| 149 | + | KeyEvent::new(code, KeyModifiers::NONE) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | fn model_with(names: &[&str]) -> Model { | |
| 153 | + | Model::new( | |
| 154 | + | names | |
| 155 | + | .iter() | |
| 156 | + | .map(|n| SourceState::new(*n, TimeDelta::seconds(60))) | |
| 157 | + | .collect(), | |
| 158 | + | ) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[test] | |
| 162 | + | fn q_and_ctrl_c_quit_and_nothing_else_does() { | |
| 163 | + | let mut model = model_with(&["sando"]); | |
| 164 | + | assert_eq!(handle_key(&mut model, key(KeyCode::Char('q'))), Flow::Quit); | |
| 165 | + | assert_eq!(handle_key(&mut model, key(KeyCode::Esc)), Flow::Quit); | |
| 166 | + | assert_eq!( | |
| 167 | + | handle_key( | |
| 168 | + | &mut model, | |
| 169 | + | KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) | |
| 170 | + | ), | |
| 171 | + | Flow::Quit | |
| 172 | + | ); | |
| 173 | + | // A bare 'c' is not a quit. | |
| 174 | + | assert_eq!( | |
| 175 | + | handle_key(&mut model, key(KeyCode::Char('c'))), | |
| 176 | + | Flow::Continue | |
| 177 | + | ); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn number_keys_jump_straight_to_a_tab() { | |
| 182 | + | let mut model = model_with(&["sando", "bento"]); | |
| 183 | + | handle_key(&mut model, key(KeyCode::Char('2'))); | |
| 184 | + | assert_eq!(model.tab, model::Tab::Source(1)); | |
| 185 | + | handle_key(&mut model, key(KeyCode::Char('0'))); | |
| 186 | + | assert_eq!(model.tab, model::Tab::Rollup); | |
| 187 | + | // Out of range is ignored rather than panicking. | |
| 188 | + | handle_key(&mut model, key(KeyCode::Char('9'))); | |
| 189 | + | assert_eq!(model.tab, model::Tab::Rollup); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | #[test] | |
| 193 | + | fn arrows_and_vim_keys_both_move() { | |
| 194 | + | let mut model = model_with(&["sando", "bento"]); | |
| 195 | + | handle_key(&mut model, key(KeyCode::Tab)); | |
| 196 | + | assert_eq!(model.tab, model::Tab::Source(0)); | |
| 197 | + | handle_key(&mut model, key(KeyCode::BackTab)); | |
| 198 | + | assert_eq!(model.tab, model::Tab::Rollup); | |
| 199 | + | handle_key(&mut model, key(KeyCode::Char('j'))); | |
| 200 | + | handle_key(&mut model, key(KeyCode::Char('k'))); | |
| 201 | + | assert_eq!(model.rollup_selected, 0); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn a_keypress_clears_a_stale_message() { | |
| 206 | + | let mut model = model_with(&["sando"]); | |
| 207 | + | model.message = Some("something happened".into()); | |
| 208 | + | handle_key(&mut model, key(KeyCode::Tab)); | |
| 209 | + | assert!(model.message.is_none()); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn a_successful_poll_replaces_a_previous_error() { | |
| 214 | + | let mut model = model_with(&["sando"]); | |
| 215 | + | apply( | |
| 216 | + | &mut model, | |
| 217 | + | poll::Update { | |
| 218 | + | index: 0, | |
| 219 | + | at: Utc::now(), | |
| 220 | + | result: Err("connection refused".into()), | |
| 221 | + | }, | |
| 222 | + | ); | |
| 223 | + | assert!(model.sources[0].error.is_some()); | |
| 224 | + | ||
| 225 | + | let at = Utc::now(); | |
| 226 | + | apply( | |
| 227 | + | &mut model, | |
| 228 | + | poll::Update { | |
| 229 | + | index: 0, | |
| 230 | + | at, | |
| 231 | + | result: Ok(Payload::new("sando", at)), | |
| 232 | + | }, | |
| 233 | + | ); | |
| 234 | + | assert!(model.sources[0].error.is_none()); | |
| 235 | + | assert!(model.sources[0].payload.is_some()); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[test] | |
| 239 | + | fn an_update_for_a_source_that_does_not_exist_is_ignored() { | |
| 240 | + | // Defensive: an index mismatch must not panic the UI thread. | |
| 241 | + | let mut model = model_with(&["sando"]); | |
| 242 | + | apply( | |
| 243 | + | &mut model, | |
| 244 | + | poll::Update { | |
| 245 | + | index: 42, | |
| 246 | + | at: Utc::now(), | |
| 247 | + | result: Err("whatever".into()), | |
| 248 | + | }, | |
| 249 | + | ); | |
| 250 | + | assert_eq!(model.sources[0].status(Utc::now()), Status::Unknown); | |
| 251 | + | } | |
| 252 | + | } |
| @@ -0,0 +1,529 @@ | |||
| 1 | + | //! What the viewer knows, and the rollup derived from it. | |
| 2 | + | //! | |
| 3 | + | //! The model holds one [`SourceState`] per configured source and nothing about | |
| 4 | + | //! what any of them mean. Everything the rollup shows is computed from the | |
| 5 | + | //! payloads alone, which is what keeps the shell from acquiring domain | |
| 6 | + | //! knowledge one convenience at a time. | |
| 7 | + | ||
| 8 | + | use chrono::{DateTime, TimeDelta, Utc}; | |
| 9 | + | use ops_status::{Node, Payload, Status}; | |
| 10 | + | ||
| 11 | + | /// One source, as last heard from. | |
| 12 | + | pub struct SourceState { | |
| 13 | + | pub name: String, | |
| 14 | + | /// The last payload that parsed. Kept across a failed poll so the screen | |
| 15 | + | /// shows the last known state alongside the fact that it is now stale, | |
| 16 | + | /// rather than going blank. | |
| 17 | + | pub payload: Option<Payload>, | |
| 18 | + | /// Why the last poll failed, if it did. | |
| 19 | + | pub error: Option<String>, | |
| 20 | + | /// When a poll last succeeded. | |
| 21 | + | pub last_ok: Option<DateTime<Utc>>, | |
| 22 | + | /// Age past which this source's answer stops counting as current. | |
| 23 | + | pub stale_after: TimeDelta, | |
| 24 | + | /// Row selection within this source's tab. | |
| 25 | + | pub selected: usize, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl SourceState { | |
| 29 | + | pub fn new(name: impl Into<String>, stale_after: TimeDelta) -> Self { | |
| 30 | + | SourceState { | |
| 31 | + | name: name.into(), | |
| 32 | + | payload: None, | |
| 33 | + | error: None, | |
| 34 | + | last_ok: None, | |
| 35 | + | stale_after, | |
| 36 | + | selected: 0, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// How old the current payload is, if there is one. | |
| 41 | + | pub fn age(&self, now: DateTime<Utc>) -> Option<TimeDelta> { | |
| 42 | + | self.payload.as_ref().map(|p| p.age(now)) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | pub fn is_stale(&self, now: DateTime<Utc>) -> bool { | |
| 46 | + | self.age(now).is_some_and(|age| age > self.stale_after) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// This source's line in the rollup. | |
| 50 | + | /// | |
| 51 | + | /// Three things can be wrong and all three are visible here: | |
| 52 | + | /// | |
| 53 | + | /// - it cannot be reached at all (`unknown`) | |
| 54 | + | /// - it answers, but with something old (`degraded` at minimum, however | |
| 55 | + | /// green its contents) | |
| 56 | + | /// - it answers freshly and reports trouble (whatever it reports) | |
| 57 | + | /// | |
| 58 | + | /// The middle case is the one that is normally missed. A backup check that | |
| 59 | + | /// answers "ok" about a snapshot taken forty days ago is not ok, and every | |
| 60 | + | /// check that existed said it was. | |
| 61 | + | pub fn status(&self, now: DateTime<Utc>) -> Status { | |
| 62 | + | let Some(payload) = &self.payload else { | |
| 63 | + | return Status::Unknown; | |
| 64 | + | }; | |
| 65 | + | if self.error.is_some() || self.is_stale(now) { | |
| 66 | + | return payload.worst_status().max(Status::Degraded); | |
| 67 | + | } | |
| 68 | + | payload.worst_status() | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// A short phrase for why this source reads the way it does. | |
| 72 | + | pub fn summary(&self, now: DateTime<Utc>) -> String { | |
| 73 | + | if let Some(error) = &self.error { | |
| 74 | + | let last = match self.last_ok { | |
| 75 | + | Some(at) => crate::value::relative(at, now), | |
| 76 | + | None => "never".into(), | |
| 77 | + | }; | |
| 78 | + | return format!("unreachable ({error}); last ok {last}"); | |
| 79 | + | } | |
| 80 | + | let Some(payload) = &self.payload else { | |
| 81 | + | return "waiting for first poll".into(); | |
| 82 | + | }; | |
| 83 | + | if self.is_stale(now) { | |
| 84 | + | return format!( | |
| 85 | + | "stale: last answered {}", | |
| 86 | + | crate::value::duration(payload.age(now).num_seconds()) | |
| 87 | + | ); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | let failing = payload | |
| 91 | + | .nodes | |
| 92 | + | .iter() | |
| 93 | + | .filter(|n| n.status >= Status::Degraded) | |
| 94 | + | .count(); | |
| 95 | + | match (failing, payload.nodes.len()) { | |
| 96 | + | (0, 1) => "1 node ok".into(), | |
| 97 | + | (0, total) => format!("{total} nodes ok"), | |
| 98 | + | (1, _) => "1 node needs attention".into(), | |
| 99 | + | (n, _) => format!("{n} nodes need attention"), | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Nodes in display order: each root followed by its children. | |
| 104 | + | /// | |
| 105 | + | /// Children are referenced by id rather than nested, so this is where the | |
| 106 | + | /// flat list becomes a tree. Depth stops at one: the contract allows deeper | |
| 107 | + | /// nesting but nothing emits it, and an unbounded recursion over | |
| 108 | + | /// producer-supplied ids is a denial-of-service waiting to happen. | |
| 109 | + | pub fn rows(&self) -> Vec<Row<'_>> { | |
| 110 | + | let Some(payload) = &self.payload else { | |
| 111 | + | return Vec::new(); | |
| 112 | + | }; | |
| 113 | + | let mut rows = Vec::new(); | |
| 114 | + | for root in payload.roots() { | |
| 115 | + | rows.push(Row { | |
| 116 | + | node: root, | |
| 117 | + | depth: 0, | |
| 118 | + | }); | |
| 119 | + | for child_id in &root.children { | |
| 120 | + | if let Some(child) = payload.node(child_id) { | |
| 121 | + | rows.push(Row { | |
| 122 | + | node: child, | |
| 123 | + | depth: 1, | |
| 124 | + | }); | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | rows | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// The node the cursor is on. | |
| 132 | + | pub fn selected_node(&self) -> Option<&Node> { | |
| 133 | + | let rows = self.rows(); | |
| 134 | + | rows.get(self.selected.min(rows.len().saturating_sub(1))) | |
| 135 | + | .map(|r| r.node) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | pub fn move_selection(&mut self, delta: isize) { | |
| 139 | + | let len = self.rows().len(); | |
| 140 | + | if len == 0 { | |
| 141 | + | self.selected = 0; | |
| 142 | + | return; | |
| 143 | + | } | |
| 144 | + | let next = self.selected as isize + delta; | |
| 145 | + | self.selected = next.clamp(0, len as isize - 1) as usize; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// Record a successful poll. | |
| 149 | + | pub fn observe(&mut self, payload: Payload, at: DateTime<Utc>) { | |
| 150 | + | self.payload = Some(payload); | |
| 151 | + | self.error = None; | |
| 152 | + | self.last_ok = Some(at); | |
| 153 | + | // A payload with fewer nodes than before must not leave the cursor | |
| 154 | + | // pointing past the end. | |
| 155 | + | let len = self.rows().len(); | |
| 156 | + | if self.selected >= len { | |
| 157 | + | self.selected = len.saturating_sub(1); | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Record a failed poll, keeping the last known payload. | |
| 162 | + | pub fn observe_error(&mut self, error: impl Into<String>) { | |
| 163 | + | self.error = Some(error.into()); | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// One line in a source tab. | |
| 168 | + | pub struct Row<'a> { | |
| 169 | + | pub node: &'a Node, | |
| 170 | + | pub depth: usize, | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// Which tab is showing. | |
| 174 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 175 | + | pub enum Tab { | |
| 176 | + | /// Every source at once, worst first. The tab that earns the product. | |
| 177 | + | Rollup, | |
| 178 | + | Source(usize), | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | pub struct Model { | |
| 182 | + | pub sources: Vec<SourceState>, | |
| 183 | + | pub tab: Tab, | |
| 184 | + | /// Cursor within the rollup tab. | |
| 185 | + | pub rollup_selected: usize, | |
| 186 | + | /// Transient message shown in the footer. | |
| 187 | + | pub message: Option<String>, | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | impl Model { | |
| 191 | + | pub fn new(sources: Vec<SourceState>) -> Self { | |
| 192 | + | Model { | |
| 193 | + | sources, | |
| 194 | + | tab: Tab::Rollup, | |
| 195 | + | rollup_selected: 0, | |
| 196 | + | message: None, | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /// Source indices ordered worst-first, then by name. | |
| 201 | + | /// | |
| 202 | + | /// Worst-first is the whole argument for the rollup existing. Sorted any | |
| 203 | + | /// other way it is a list you still have to read all of, which is the | |
| 204 | + | /// situation it replaces. | |
| 205 | + | pub fn rollup_order(&self, now: DateTime<Utc>) -> Vec<usize> { | |
| 206 | + | let mut order: Vec<usize> = (0..self.sources.len()).collect(); | |
| 207 | + | order.sort_by(|&a, &b| { | |
| 208 | + | let (sa, sb) = (self.sources[a].status(now), self.sources[b].status(now)); | |
| 209 | + | sb.cmp(&sa) | |
| 210 | + | .then_with(|| self.sources[a].name.cmp(&self.sources[b].name)) | |
| 211 | + | }); | |
| 212 | + | order | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | /// The worst status across every source: the one thing to look at first. | |
| 216 | + | pub fn worst(&self, now: DateTime<Utc>) -> Status { | |
| 217 | + | self.sources | |
| 218 | + | .iter() | |
| 219 | + | .map(|s| s.status(now)) | |
| 220 | + | .max() | |
| 221 | + | .unwrap_or(Status::Unknown) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | pub fn tab_titles(&self) -> Vec<String> { | |
| 225 | + | let mut titles = vec!["rollup".to_string()]; | |
| 226 | + | titles.extend(self.sources.iter().map(|s| s.name.clone())); | |
| 227 | + | titles | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | pub fn tab_index(&self) -> usize { | |
| 231 | + | match self.tab { | |
| 232 | + | Tab::Rollup => 0, | |
| 233 | + | Tab::Source(i) => i + 1, | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | pub fn select_tab(&mut self, index: usize) { | |
| 238 | + | self.tab = match index { | |
| 239 | + | 0 => Tab::Rollup, | |
| 240 | + | n if n <= self.sources.len() => Tab::Source(n - 1), | |
| 241 | + | _ => self.tab, | |
| 242 | + | }; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | pub fn next_tab(&mut self) { | |
| 246 | + | let next = (self.tab_index() + 1) % (self.sources.len() + 1); | |
| 247 | + | self.select_tab(next); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | pub fn prev_tab(&mut self) { | |
| 251 | + | let count = self.sources.len() + 1; | |
| 252 | + | let next = (self.tab_index() + count - 1) % count; | |
| 253 | + | self.select_tab(next); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// Move the cursor in whichever tab is showing. | |
| 257 | + | pub fn move_selection(&mut self, delta: isize, now: DateTime<Utc>) { | |
| 258 | + | match self.tab { | |
| 259 | + | Tab::Rollup => { | |
| 260 | + | let len = self.rollup_order(now).len(); | |
| 261 | + | if len == 0 { | |
| 262 | + | return; | |
| 263 | + | } | |
| 264 | + | let next = self.rollup_selected as isize + delta; | |
| 265 | + | self.rollup_selected = next.clamp(0, len as isize - 1) as usize; | |
| 266 | + | } | |
| 267 | + | Tab::Source(i) => { | |
| 268 | + | if let Some(source) = self.sources.get_mut(i) { | |
| 269 | + | source.move_selection(delta); | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | /// Enter on a rollup row opens that source's tab. | |
| 276 | + | pub fn open_selected(&mut self, now: DateTime<Utc>) { | |
| 277 | + | if self.tab == Tab::Rollup { | |
| 278 | + | let order = self.rollup_order(now); | |
| 279 | + | if let Some(&index) = order.get(self.rollup_selected) { | |
| 280 | + | self.tab = Tab::Source(index); | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[cfg(test)] | |
| 287 | + | mod tests { | |
| 288 | + | use super::*; | |
| 289 | + | use ops_status::{Condition, Node}; | |
| 290 | + | ||
| 291 | + | fn now() -> DateTime<Utc> { | |
| 292 | + | "2026-07-21T18:00:00Z".parse().unwrap() | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | fn node(id: &str, status: Status, children: Vec<&str>) -> Node { | |
| 296 | + | Node { | |
| 297 | + | id: id.into(), | |
| 298 | + | kind: "tier".into(), | |
| 299 | + | label: id.into(), | |
| 300 | + | status, | |
| 301 | + | fields: Vec::new(), | |
| 302 | + | conditions: Vec::new(), | |
| 303 | + | children: children.into_iter().map(Into::into).collect(), | |
| 304 | + | actions: Vec::new(), | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | fn payload(at: DateTime<Utc>, nodes: Vec<Node>) -> Payload { | |
| 309 | + | let mut p = Payload::new("sando", at); | |
| 310 | + | p.nodes = nodes; | |
| 311 | + | p | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState { | |
| 315 | + | let mut s = SourceState::new(name, TimeDelta::seconds(60)); | |
| 316 | + | s.observe(payload(at, nodes), at); | |
| 317 | + | s | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | #[test] | |
| 321 | + | fn a_source_never_polled_is_unknown_not_ok() { | |
| 322 | + | let s = SourceState::new("sando", TimeDelta::seconds(60)); | |
| 323 | + | assert_eq!(s.status(now()), Status::Unknown); | |
| 324 | + | assert_eq!(s.summary(now()), "waiting for first poll"); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn a_fresh_healthy_source_is_ok() { | |
| 329 | + | let s = source("sando", now(), vec![node("tier:b", Status::Ok, vec![])]); | |
| 330 | + | assert_eq!(s.status(now()), Status::Ok); | |
| 331 | + | assert_eq!(s.summary(now()), "1 node ok"); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[test] | |
| 335 | + | fn a_stale_but_green_source_is_degraded() { | |
| 336 | + | // The forty-day-old backup that every check called healthy. | |
| 337 | + | let s = source( | |
| 338 | + | "pom", | |
| 339 | + | now() - TimeDelta::hours(4), | |
| 340 | + | vec![node("backup", Status::Ok, vec![])], | |
| 341 | + | ); | |
| 342 | + | assert_eq!(s.status(now()), Status::Degraded); | |
| 343 | + | assert!( | |
| 344 | + | s.summary(now()).starts_with("stale:"), | |
| 345 | + | "{}", | |
| 346 | + | s.summary(now()) | |
| 347 | + | ); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[test] | |
| 351 | + | fn staleness_never_downgrades_a_worse_status() { | |
| 352 | + | let mut s = source( | |
| 353 | + | "sando", | |
| 354 | + | now() - TimeDelta::hours(4), | |
| 355 | + | vec![node("tier:b", Status::Failed, vec![])], | |
| 356 | + | ); | |
| 357 | + | assert_eq!(s.status(now()), Status::Failed); | |
| 358 | + | s.observe_error("connection refused"); | |
| 359 | + | assert_eq!(s.status(now()), Status::Failed); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | #[test] | |
| 363 | + | fn an_unreachable_source_keeps_its_last_payload_and_says_when() { | |
| 364 | + | let mut s = source("bento", now(), vec![node("app:x", Status::Ok, vec![])]); | |
| 365 | + | s.observe_error("connection refused"); | |
| 366 | + | // Degraded, not Unknown: we still have a recent answer, we just could | |
| 367 | + | // not refresh it. | |
| 368 | + | assert_eq!(s.status(now()), Status::Degraded); | |
| 369 | + | assert!( | |
| 370 | + | s.payload.is_some(), | |
| 371 | + | "the last known state must not go blank" | |
| 372 | + | ); | |
| 373 | + | let summary = s.summary(now()); | |
| 374 | + | assert!(summary.contains("connection refused"), "{summary}"); | |
| 375 | + | assert!(summary.contains("last ok"), "{summary}"); | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | #[test] | |
| 379 | + | fn a_source_that_never_answered_and_then_failed_is_unknown() { | |
| 380 | + | let mut s = SourceState::new("bento", TimeDelta::seconds(60)); | |
| 381 | + | s.observe_error("connection refused"); | |
| 382 | + | assert_eq!(s.status(now()), Status::Unknown); | |
| 383 | + | assert!(s.summary(now()).contains("last ok never")); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | #[test] | |
| 387 | + | fn rows_put_children_under_their_parent() { | |
| 388 | + | let s = source( | |
| 389 | + | "sando", | |
| 390 | + | now(), | |
| 391 | + | vec![ | |
| 392 | + | node("tier:b", Status::Ok, vec!["node:prod-1"]), | |
| 393 | + | node("node:prod-1", Status::Ok, vec![]), | |
| 394 | + | ], | |
| 395 | + | ); | |
| 396 | + | let rows = s.rows(); | |
| 397 | + | assert_eq!(rows.len(), 2); | |
| 398 | + | assert_eq!(rows[0].node.id, "tier:b"); | |
| 399 | + | assert_eq!(rows[0].depth, 0); | |
| 400 | + | assert_eq!(rows[1].node.id, "node:prod-1"); | |
| 401 | + | assert_eq!(rows[1].depth, 1); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | #[test] | |
| 405 | + | fn a_dangling_child_reference_is_skipped_not_fatal() { | |
| 406 | + | let s = source( | |
| 407 | + | "sando", | |
| 408 | + | now(), | |
| 409 | + | vec![node("tier:b", Status::Ok, vec!["node:ghost"])], | |
| 410 | + | ); | |
| 411 | + | assert_eq!(s.rows().len(), 1); | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | #[test] | |
| 415 | + | fn the_rollup_puts_the_worst_source_first() { | |
| 416 | + | let m = Model::new(vec![ | |
| 417 | + | source("aaa", now(), vec![node("n", Status::Ok, vec![])]), | |
| 418 | + | source("bbb", now(), vec![node("n", Status::Failed, vec![])]), | |
| 419 | + | source("ccc", now(), vec![node("n", Status::Degraded, vec![])]), | |
| 420 | + | ]); | |
| 421 | + | let order = m.rollup_order(now()); | |
| 422 | + | let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect(); | |
| 423 | + | assert_eq!(names, vec!["bbb", "ccc", "aaa"]); | |
| 424 | + | assert_eq!(m.worst(now()), Status::Failed); | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | #[test] | |
| 428 | + | fn an_unreachable_source_outranks_a_merely_degraded_one() { | |
| 429 | + | let m = Model::new(vec![ | |
| 430 | + | source("degraded", now(), vec![node("n", Status::Degraded, vec![])]), | |
| 431 | + | SourceState::new("silent", TimeDelta::seconds(60)), | |
| 432 | + | ]); | |
| 433 | + | let order = m.rollup_order(now()); | |
| 434 | + | assert_eq!(m.sources[order[0]].name, "silent"); | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | #[test] | |
| 438 | + | fn equal_statuses_sort_by_name_so_the_order_does_not_jitter() { | |
| 439 | + | let m = Model::new(vec![ | |
| 440 | + | source("zebra", now(), vec![node("n", Status::Ok, vec![])]), | |
| 441 | + | source("alpha", now(), vec![node("n", Status::Ok, vec![])]), | |
| 442 | + | ]); | |
| 443 | + | let order = m.rollup_order(now()); | |
| 444 | + | let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect(); | |
| 445 | + | assert_eq!(names, vec!["alpha", "zebra"]); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | #[test] | |
| 449 | + | fn tabs_wrap_in_both_directions() { | |
| 450 | + | let mut m = Model::new(vec![source("a", now(), vec![]), source("b", now(), vec![])]); | |
| 451 | + | assert_eq!(m.tab, Tab::Rollup); | |
| 452 | + | m.next_tab(); | |
| 453 | + | assert_eq!(m.tab, Tab::Source(0)); | |
| 454 | + | m.next_tab(); | |
| 455 | + | assert_eq!(m.tab, Tab::Source(1)); | |
| 456 | + | m.next_tab(); | |
| 457 | + | assert_eq!(m.tab, Tab::Rollup); | |
| 458 | + | m.prev_tab(); | |
| 459 | + | assert_eq!(m.tab, Tab::Source(1)); | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | #[test] | |
| 463 | + | fn enter_on_the_rollup_opens_the_worst_source() { | |
| 464 | + | let mut m = Model::new(vec![ | |
| 465 | + | source("healthy", now(), vec![node("n", Status::Ok, vec![])]), | |
| 466 | + | source("broken", now(), vec![node("n", Status::Failed, vec![])]), | |
| 467 | + | ]); | |
| 468 | + | m.open_selected(now()); | |
| 469 | + | assert_eq!( | |
| 470 | + | m.tab, | |
| 471 | + | Tab::Source(1), | |
| 472 | + | "the first rollup row is the worst source" | |
| 473 | + | ); | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | #[test] | |
| 477 | + | fn selection_cannot_run_off_either_end() { | |
| 478 | + | let mut s = source( | |
| 479 | + | "sando", | |
| 480 | + | now(), | |
| 481 | + | vec![node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![])], | |
| 482 | + | ); | |
| 483 | + | s.move_selection(-5); | |
| 484 | + | assert_eq!(s.selected, 0); | |
| 485 | + | s.move_selection(99); | |
| 486 | + | assert_eq!(s.selected, 1); | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | #[test] | |
| 490 | + | fn a_shrinking_payload_pulls_the_cursor_back_in_bounds() { | |
| 491 | + | // A poll that returns fewer nodes must not leave the cursor dangling. | |
| 492 | + | let mut s = source( | |
| 493 | + | "sando", | |
| 494 | + | now(), | |
| 495 | + | vec![ | |
| 496 | + | node("a", Status::Ok, vec![]), | |
| 497 | + | node("b", Status::Ok, vec![]), | |
| 498 | + | node("c", Status::Ok, vec![]), | |
| 499 | + | ], | |
| 500 | + | ); |
Lines truncated
| @@ -0,0 +1,170 @@ | |||
| 1 | + | //! Fetching each source on its own interval. | |
| 2 | + | //! | |
| 3 | + | //! One task per source, each reporting into a channel the UI drains. Sources | |
| 4 | + | //! are independent on purpose: a daemon that hangs must not stop the others | |
| 5 | + | //! from updating, because the tab that stops updating is exactly the one you | |
| 6 | + | //! need to see. | |
| 7 | + | ||
| 8 | + | use std::time::Duration; | |
| 9 | + | ||
| 10 | + | use chrono::Utc; | |
| 11 | + | use ops_status::Payload; | |
| 12 | + | use tokio::sync::mpsc; | |
| 13 | + | ||
| 14 | + | use crate::config::Source; | |
| 15 | + | ||
| 16 | + | /// A poll result, tagged with which source it came from. | |
| 17 | + | pub struct Update { | |
| 18 | + | pub index: usize, | |
| 19 | + | pub at: chrono::DateTime<Utc>, | |
| 20 | + | pub result: Result<Payload, String>, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// How long a single request may take before it counts as unreachable. | |
| 24 | + | /// | |
| 25 | + | /// Bounded well under any sane poll interval: a request that outlives its own | |
| 26 | + | /// interval would stack tasks up behind it, and a source that is merely slow | |
| 27 | + | /// should read as a problem rather than silently delaying every later poll. | |
| 28 | + | const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); | |
| 29 | + | ||
| 30 | + | /// Spawn one polling task per source. Returns the receiving end. | |
| 31 | + | pub fn spawn_all(sources: &[Source]) -> mpsc::Receiver<Update> { | |
| 32 | + | // Capacity comfortably exceeds one in-flight update per source, so a | |
| 33 | + | // momentarily busy UI loop cannot make a poller block or drop a result. | |
| 34 | + | let (tx, rx) = mpsc::channel(sources.len().max(1) * 4); | |
| 35 | + | for (index, source) in sources.iter().enumerate() { | |
| 36 | + | tokio::spawn(poll_forever(index, source.clone(), tx.clone())); | |
| 37 | + | } | |
| 38 | + | rx | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | async fn poll_forever(index: usize, source: Source, tx: mpsc::Sender<Update>) { | |
| 42 | + | let client = match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() { | |
| 43 | + | Ok(client) => client, | |
| 44 | + | Err(e) => { | |
| 45 | + | let _ = tx | |
| 46 | + | .send(Update { | |
| 47 | + | index, | |
| 48 | + | at: Utc::now(), | |
| 49 | + | result: Err(format!("client: {e}")), | |
| 50 | + | }) | |
| 51 | + | .await; | |
| 52 | + | return; | |
| 53 | + | } | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | let url = source.status_url(); | |
| 57 | + | let mut ticker = tokio::time::interval(source.poll_interval()); | |
| 58 | + | // A poll that overruns its interval must not cause a burst of catch-up | |
| 59 | + | // requests at the next tick. | |
| 60 | + | ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); | |
| 61 | + | ||
| 62 | + | loop { | |
| 63 | + | ticker.tick().await; | |
| 64 | + | let result = fetch(&client, &url, source.token().as_deref()).await; | |
| 65 | + | // The token is read per request rather than captured once, so rotating | |
| 66 | + | // it does not require restarting the viewer. | |
| 67 | + | if tx | |
| 68 | + | .send(Update { | |
| 69 | + | index, | |
| 70 | + | at: Utc::now(), | |
| 71 | + | result, | |
| 72 | + | }) | |
| 73 | + | .await | |
| 74 | + | .is_err() | |
| 75 | + | { | |
| 76 | + | return; // UI is gone | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | async fn fetch( | |
| 82 | + | client: &reqwest::Client, | |
| 83 | + | url: &str, | |
| 84 | + | token: Option<&str>, | |
| 85 | + | ) -> Result<Payload, String> { | |
| 86 | + | let mut request = client.get(url); | |
| 87 | + | if let Some(token) = token { | |
| 88 | + | request = request.bearer_auth(token); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | let response = request.send().await.map_err(short_error)?; | |
| 92 | + | let status = response.status(); | |
| 93 | + | if !status.is_success() { | |
| 94 | + | // 401 is worth its own words: it is the most likely misconfiguration | |
| 95 | + | // and the least self-evident from a bare status code. | |
| 96 | + | return Err(match status.as_u16() { | |
| 97 | + | 401 | 403 => "unauthorized (check the source's token_env)".into(), | |
| 98 | + | code => format!("HTTP {code}"), | |
| 99 | + | }); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | let body = response.bytes().await.map_err(short_error)?; | |
| 103 | + | serde_json::from_slice(&body).map_err(|e| format!("bad payload: {e}")) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// reqwest errors stringify into a paragraph with a full URL chain. The rollup | |
| 107 | + | /// has one column for this, so keep the part that says what went wrong. | |
| 108 | + | fn short_error(e: reqwest::Error) -> String { | |
| 109 | + | if e.is_timeout() { | |
| 110 | + | return "timed out".into(); | |
| 111 | + | } | |
| 112 | + | if e.is_connect() { | |
| 113 | + | return "connection refused".into(); | |
| 114 | + | } | |
| 115 | + | if e.is_decode() { | |
| 116 | + | return "bad response body".into(); | |
| 117 | + | } | |
| 118 | + | let text = e.to_string(); | |
| 119 | + | text.split(':') | |
| 120 | + | .next_back() | |
| 121 | + | .unwrap_or(&text) | |
| 122 | + | .trim() | |
| 123 | + | .to_string() | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[cfg(test)] | |
| 127 | + | mod tests { | |
| 128 | + | use super::*; | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn a_status_url_is_built_from_the_configured_base() { | |
| 132 | + | let source: Source = toml::from_str( | |
| 133 | + | r#" | |
| 134 | + | name = "sando" | |
| 135 | + | url = "http://fw13:8080" | |
| 136 | + | "#, | |
| 137 | + | ) | |
| 138 | + | .unwrap(); | |
| 139 | + | assert_eq!(source.status_url(), "http://fw13:8080/status.json"); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[tokio::test] | |
| 143 | + | async fn an_unreachable_host_reports_a_short_reason() { | |
| 144 | + | let client = reqwest::Client::builder() | |
| 145 | + | .timeout(Duration::from_millis(300)) | |
| 146 | + | .build() | |
| 147 | + | .unwrap(); | |
| 148 | + | // Port 1 on loopback: nothing listens, so this refuses fast. | |
| 149 | + | let err = fetch(&client, "http://127.0.0.1:1/status.json", None) | |
| 150 | + | .await | |
| 151 | + | .unwrap_err(); | |
| 152 | + | assert!( | |
| 153 | + | err.len() < 60, | |
| 154 | + | "the rollup has one column for this: {err:?}" | |
| 155 | + | ); | |
| 156 | + | assert!( | |
| 157 | + | !err.contains("http://"), | |
| 158 | + | "no URL chain in the summary: {err:?}" | |
| 159 | + | ); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | #[tokio::test] | |
| 163 | + | async fn a_body_that_is_not_a_payload_is_an_error_not_a_panic() { | |
| 164 | + | let client = reqwest::Client::new(); | |
| 165 | + | let bad: Result<Payload, String> = | |
| 166 | + | serde_json::from_slice(b"not json").map_err(|e| format!("bad payload: {e}")); | |
| 167 | + | assert!(bad.unwrap_err().starts_with("bad payload")); | |
| 168 | + | drop(client); | |
| 169 | + | } | |
| 170 | + | } |
| @@ -0,0 +1,514 @@ | |||
| 1 | + | //! Drawing the model. | |
| 2 | + | //! | |
| 3 | + | //! `render(model, now, frame)` is a pure function of the model and the clock. | |
| 4 | + | //! `now` is threaded through rather than read from the system clock precisely | |
| 5 | + | //! so that this stays true: with an ambient clock, every snapshot test becomes | |
| 6 | + | //! time-dependent and the whole surface stops being verifiable without a live | |
| 7 | + | //! daemon. That is unpleasant to retrofit and trivial to keep. | |
| 8 | + | ||
| 9 | + | use chrono::{DateTime, Utc}; | |
| 10 | + | use ops_status::Node; | |
| 11 | + | use ratatui::Frame; | |
| 12 | + | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 13 | + | use ratatui::style::{Modifier, Style, Stylize}; | |
| 14 | + | use ratatui::text::{Line, Span}; | |
| 15 | + | use ratatui::widgets::{Block, Cell, List, ListItem, Paragraph, Row, Table, Tabs}; | |
| 16 | + | ||
| 17 | + | use crate::model::{Model, SourceState, Tab}; | |
| 18 | + | use crate::value; | |
| 19 | + | ||
| 20 | + | pub fn render(model: &Model, now: DateTime<Utc>, frame: &mut Frame) { | |
| 21 | + | let [header, body, footer] = Layout::vertical([ | |
| 22 | + | Constraint::Length(1), | |
| 23 | + | Constraint::Min(1), | |
| 24 | + | Constraint::Length(1), | |
| 25 | + | ]) | |
| 26 | + | .areas(frame.area()); | |
| 27 | + | ||
| 28 | + | render_header(model, now, frame, header); | |
| 29 | + | match model.tab { | |
| 30 | + | Tab::Rollup => render_rollup(model, now, frame, body), | |
| 31 | + | Tab::Source(i) => match model.sources.get(i) { | |
| 32 | + | Some(source) => render_source(source, now, frame, body), | |
| 33 | + | None => frame.render_widget(Paragraph::new("no such source"), body), | |
| 34 | + | }, | |
| 35 | + | } | |
| 36 | + | render_footer(model, frame, footer); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | fn render_header(model: &Model, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 40 | + | let worst = model.worst(now); | |
| 41 | + | let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area); | |
| 42 | + | ||
| 43 | + | frame.render_widget( | |
| 44 | + | Paragraph::new(Span::styled( | |
| 45 | + | format!(" {} ", value::status_mark(worst)), | |
| 46 | + | value::status_style(worst).add_modifier(Modifier::REVERSED), | |
| 47 | + | )), | |
| 48 | + | mark, | |
| 49 | + | ); | |
| 50 | + | frame.render_widget( | |
| 51 | + | Tabs::new(model.tab_titles()) | |
| 52 | + | .select(model.tab_index()) | |
| 53 | + | .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) | |
| 54 | + | .divider(" "), | |
| 55 | + | tabs, | |
| 56 | + | ); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fn render_footer(model: &Model, frame: &mut Frame, area: Rect) { | |
| 60 | + | let text = match &model.message { | |
| 61 | + | Some(message) => Span::from(format!(" {message}")).yellow(), | |
| 62 | + | None => { | |
| 63 | + | Span::from(" tab/shift-tab switch up/down move enter open 1-9 jump q quit").dim() | |
| 64 | + | } | |
| 65 | + | }; | |
| 66 | + | frame.render_widget(Paragraph::new(Line::from(text)), area); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // --------------------------------------------------------------------------- | |
| 70 | + | // Rollup | |
| 71 | + | // --------------------------------------------------------------------------- | |
| 72 | + | ||
| 73 | + | /// Every source at once, worst first. | |
| 74 | + | /// | |
| 75 | + | /// Without this the viewer is N tabs you still have to visit one at a time, | |
| 76 | + | /// which is the situation it replaces, with extra steps. | |
| 77 | + | fn render_rollup(model: &Model, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 78 | + | let order = model.rollup_order(now); | |
| 79 | + | let rows: Vec<Row> = order | |
| 80 | + | .iter() | |
| 81 | + | .enumerate() | |
| 82 | + | .map(|(row, &index)| { | |
| 83 | + | let source = &model.sources[index]; | |
| 84 | + | let status = source.status(now); | |
| 85 | + | let age = match source.age(now) { | |
| 86 | + | Some(age) => value::duration(age.num_seconds()), | |
| 87 | + | None => "-".into(), | |
| 88 | + | }; | |
| 89 | + | let style = if row == model.rollup_selected { | |
| 90 | + | Style::default().add_modifier(Modifier::REVERSED) | |
| 91 | + | } else { | |
| 92 | + | Style::default() | |
| 93 | + | }; | |
| 94 | + | Row::new(vec![ | |
| 95 | + | Cell::from(value::status_mark(status)).style(value::status_style(status)), | |
| 96 | + | Cell::from(source.name.clone()), | |
| 97 | + | Cell::from(age), | |
| 98 | + | Cell::from(source.summary(now)), | |
| 99 | + | ]) | |
| 100 | + | .style(style) | |
| 101 | + | }) | |
| 102 | + | .collect(); | |
| 103 | + | ||
| 104 | + | let table = Table::new( | |
| 105 | + | rows, | |
| 106 | + | [ | |
| 107 | + | Constraint::Length(4), | |
| 108 | + | Constraint::Length(14), | |
| 109 | + | Constraint::Length(8), | |
| 110 | + | Constraint::Min(10), | |
| 111 | + | ], | |
| 112 | + | ) | |
| 113 | + | .header( | |
| 114 | + | Row::new(vec!["", "source", "age", "detail"]) | |
| 115 | + | .style(Style::default().add_modifier(Modifier::DIM)), | |
| 116 | + | ) | |
| 117 | + | .block(Block::bordered().title(" all sources ")); | |
| 118 | + | ||
| 119 | + | frame.render_widget(table, area); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // --------------------------------------------------------------------------- | |
| 123 | + | // One source | |
| 124 | + | // --------------------------------------------------------------------------- | |
| 125 | + | ||
| 126 | + | fn render_source(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 127 | + | let [list_area, detail_area] = | |
| 128 | + | Layout::vertical([Constraint::Percentage(55), Constraint::Min(5)]).areas(area); | |
| 129 | + | ||
| 130 | + | let rows = source.rows(); | |
| 131 | + | let items: Vec<ListItem> = rows | |
| 132 | + | .iter() | |
| 133 | + | .enumerate() | |
| 134 | + | .map(|(i, row)| { | |
| 135 | + | let indent = " ".repeat(row.depth); | |
| 136 | + | let mut spans = vec![ | |
| 137 | + | Span::styled( | |
| 138 | + | format!("{:<5}", value::status_mark(row.node.status)), | |
| 139 | + | value::status_style(row.node.status), | |
| 140 | + | ), | |
| 141 | + | Span::raw(format!("{indent}{}", row.node.label)), | |
| 142 | + | Span::styled(format!(" ({})", row.node.kind), Style::default().dim()), | |
| 143 | + | ]; | |
| 144 | + | if i == source.selected { | |
| 145 | + | spans = spans | |
| 146 | + | .into_iter() | |
| 147 | + | .map(|s| s.patch_style(Style::default().add_modifier(Modifier::REVERSED))) | |
| 148 | + | .collect(); | |
| 149 | + | } | |
| 150 | + | ListItem::new(Line::from(spans)) | |
| 151 | + | }) | |
| 152 | + | .collect(); | |
| 153 | + | ||
| 154 | + | let title = format!(" {} ", source.name); | |
| 155 | + | let list = if items.is_empty() { | |
| 156 | + | List::new(vec![ListItem::new(Line::from( | |
| 157 | + | Span::from(source.summary(now)).dim(), | |
| 158 | + | ))]) | |
| 159 | + | } else { | |
| 160 | + | List::new(items) | |
| 161 | + | }; | |
| 162 | + | frame.render_widget(list.block(Block::bordered().title(title)), list_area); | |
| 163 | + | ||
| 164 | + | render_detail(source, now, frame, detail_area); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The selected node's fields and conditions. | |
| 168 | + | /// | |
| 169 | + | /// Conditions are the half that usually gets dropped, and the half that pays: | |
| 170 | + | /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves | |
| 171 | + | /// an SSH. | |
| 172 | + | fn render_detail(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, area: Rect) { | |
| 173 | + | let width = area.width.saturating_sub(4) as usize; | |
| 174 | + | let mut lines: Vec<Line> = Vec::new(); | |
| 175 | + | ||
| 176 | + | match source.selected_node() { | |
| 177 | + | Some(node) => { | |
| 178 | + | lines.push(Line::from(vec![ | |
| 179 | + | Span::styled( | |
| 180 | + | value::status_mark(node.status), | |
| 181 | + | value::status_style(node.status), | |
| 182 | + | ), | |
| 183 | + | Span::raw(" "), | |
| 184 | + | Span::styled(node.label.clone(), Style::default().bold()), | |
| 185 | + | ])); | |
| 186 | + | lines.extend(field_lines(node, now, width)); | |
| 187 | + | lines.extend(condition_lines(node, width)); | |
| 188 | + | if !node.actions.is_empty() { | |
| 189 | + | lines.push(Line::from( | |
| 190 | + | Span::from(format!("actions: {}", node.actions.join(", "))).dim(), | |
| 191 | + | )); | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | None => lines.push(Line::from(Span::from(source.summary(now)).dim())), | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | frame.render_widget( | |
| 198 | + | Paragraph::new(lines).block(Block::bordered().title(" detail ")), | |
| 199 | + | area, | |
| 200 | + | ); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | fn field_lines(node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> { | |
| 204 | + | let label_width = node | |
| 205 | + | .fields | |
| 206 | + | .iter() | |
| 207 | + | .map(|f| f.label.chars().count()) | |
| 208 | + | .max() | |
| 209 | + | .unwrap_or(0); | |
| 210 | + | ||
| 211 | + | node.fields | |
| 212 | + | .iter() | |
| 213 | + | .map(|field| { | |
| 214 | + | let budget = width.saturating_sub(label_width + 2); | |
| 215 | + | let rendered = value::render(&field.value, now, budget); | |
| 216 | + | let style = match &field.value { | |
| 217 | + | ops_status::Value::Progress { value, max, .. } => { | |
| 218 | + | value::progress_style(*value, *max) | |
| 219 | + | } | |
| 220 | + | other => value::style(other), | |
| 221 | + | }; | |
| 222 | + | Line::from(vec![ | |
| 223 | + | Span::styled( | |
| 224 | + | format!("{:<label_width$} ", field.label), | |
| 225 | + | Style::default().dim(), | |
| 226 | + | ), | |
| 227 | + | Span::styled(rendered, style), | |
| 228 | + | ]) | |
| 229 | + | }) | |
| 230 | + | .collect() | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | fn condition_lines(node: &Node, width: usize) -> Vec<Line<'static>> { | |
| 234 | + | node.conditions | |
| 235 | + | .iter() | |
| 236 | + | .map(|condition| { | |
| 237 | + | let mut spans = vec![ | |
| 238 | + | Span::styled( | |
| 239 | + | format!("{:<5}", value::status_mark(condition.status)), | |
| 240 | + | value::status_style(condition.status), | |
| 241 | + | ), | |
| 242 | + | Span::raw(condition.condition_type.clone()), | |
| 243 | + | ]; | |
| 244 | + | if let Some(detail) = &condition.detail { | |
| 245 | + | let budget = width.saturating_sub(condition.condition_type.chars().count() + 8); | |
| 246 | + | spans.push(Span::styled( | |
| 247 | + | format!(" {}", truncate(detail, budget.max(8))), | |
| 248 | + | Style::default().dim(), | |
| 249 | + | )); | |
| 250 | + | } | |
| 251 | + | Line::from(spans) | |
| 252 | + | }) | |
| 253 | + | .collect() | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// Clip to a character budget, marking that something was cut. | |
| 257 | + | fn truncate(text: &str, max: usize) -> String { | |
| 258 | + | // A detail string is producer-supplied and may carry newlines; the pane is | |
| 259 | + | // line-oriented, so flatten first. | |
| 260 | + | let flat: String = text.replace('\n', " "); | |
| 261 | + | if flat.chars().count() <= max { | |
| 262 | + | return flat; | |
| 263 | + | } | |
| 264 | + | let kept: String = flat.chars().take(max.saturating_sub(1)).collect(); | |
| 265 | + | format!("{kept}…") | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[cfg(test)] | |
| 269 | + | mod tests { | |
| 270 | + | use super::*; | |
| 271 | + | use crate::model::SourceState; | |
| 272 | + | use chrono::TimeDelta; | |
| 273 | + | use ops_status::{Condition, Field, Payload, Status, Value}; | |
| 274 | + | use ratatui::Terminal; | |
| 275 | + | use ratatui::backend::TestBackend; | |
| 276 | + | ||
| 277 | + | fn now() -> DateTime<Utc> { | |
| 278 | + | "2026-07-21T18:00:00Z".parse().unwrap() | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /// Render a model into a fixed-size buffer and return it as text lines. | |
| 282 | + | /// | |
| 283 | + | /// This is the whole payoff of keeping render pure: the entire surface is | |
| 284 | + | /// verifiable with no daemon running and no terminal attached. | |
| 285 | + | fn draw(model: &Model, now: DateTime<Utc>, width: u16, height: u16) -> Vec<String> { | |
| 286 | + | let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); | |
| 287 | + | terminal.draw(|frame| render(model, now, frame)).unwrap(); | |
| 288 | + | let buffer = terminal.backend().buffer().clone(); | |
| 289 | + | (0..buffer.area.height) | |
| 290 | + | .map(|y| { | |
| 291 | + | (0..buffer.area.width) | |
| 292 | + | .map(|x| buffer[(x, y)].symbol().to_string()) | |
| 293 | + | .collect::<String>() | |
| 294 | + | .trim_end() | |
| 295 | + | .to_string() | |
| 296 | + | }) | |
| 297 | + | .collect() | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | fn node(id: &str, label: &str, status: Status) -> Node { | |
| 301 | + | Node { | |
| 302 | + | id: id.into(), | |
| 303 | + | kind: "tier".into(), | |
| 304 | + | label: label.into(), | |
| 305 | + | status, | |
| 306 | + | fields: Vec::new(), | |
| 307 | + | conditions: Vec::new(), | |
| 308 | + | children: Vec::new(), | |
| 309 | + | actions: Vec::new(), | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState { | |
| 314 | + | let mut s = SourceState::new(name, TimeDelta::seconds(60)); | |
| 315 | + | let mut p = Payload::new(name, at); | |
| 316 | + | p.nodes = nodes; | |
| 317 | + | s.observe(p, at); | |
| 318 | + | s | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | fn joined(lines: &[String]) -> String { | |
| 322 | + | lines.join("\n") | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[test] | |
| 326 | + | fn the_rollup_leads_with_the_worst_source() { | |
| 327 | + | let model = Model::new(vec![ | |
| 328 | + | source("sando", now(), vec![node("a", "tier a", Status::Ok)]), | |
| 329 | + | source("bento", now(), vec![node("b", "goingson", Status::Failed)]), | |
| 330 | + | ]); | |
| 331 | + | let lines = draw(&model, now(), 80, 12); | |
| 332 | + | let text = joined(&lines); | |
| 333 | + | ||
| 334 | + | assert!(text.contains("rollup"), "{text}"); | |
| 335 | + | // Skip the tab bar, which names every source regardless of order. | |
| 336 | + | let body = &lines[1..]; | |
| 337 | + | let bento = body.iter().position(|l| l.contains("bento")).unwrap(); | |
| 338 | + | let sando = body.iter().position(|l| l.contains("sando")).unwrap(); | |
| 339 | + | assert!(bento < sando, "the failing source must be on top:\n{text}"); | |
| 340 | + | assert!(text.contains("FAIL"), "{text}"); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | #[test] | |
| 344 | + | fn a_source_that_has_never_answered_says_so_rather_than_showing_nothing() { | |
| 345 | + | let model = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]); | |
| 346 | + | let text = joined(&draw(&model, now(), 80, 12)); | |
| 347 | + | assert!( | |
| 348 | + | text.contains("????"), | |
| 349 | + | "an unreachable source must be loud:\n{text}" | |
| 350 | + | ); | |
| 351 | + | assert!(text.contains("waiting for first poll"), "{text}"); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | #[test] | |
| 355 | + | fn a_stale_source_shows_its_age_in_the_rollup() { | |
| 356 | + | let model = Model::new(vec![source( | |
| 357 | + | "pom", | |
| 358 | + | now() - TimeDelta::hours(4), | |
| 359 | + | vec![node("backup", "backup", Status::Ok)], | |
| 360 | + | )]); | |
| 361 | + | let text = joined(&draw(&model, now(), 80, 12)); | |
| 362 | + | assert!(text.contains("4h"), "the age must be visible:\n{text}"); | |
| 363 | + | assert!(text.contains("degr"), "stale-but-green is not ok:\n{text}"); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[test] | |
| 367 | + | fn a_source_tab_lists_its_nodes_with_children_indented() { | |
| 368 | + | let mut parent = node("tier:b", "b (prod-1)", Status::Ok); | |
| 369 | + | parent.children = vec!["node:prod-1".into()]; | |
| 370 | + | let child = node("node:prod-1", "prod-1", Status::Ok); | |
| 371 | + | ||
| 372 | + | let mut model = Model::new(vec![source("sando", now(), vec![parent, child])]); | |
| 373 | + | model.select_tab(1); | |
| 374 | + | let lines = draw(&model, now(), 80, 20); | |
| 375 | + | let text = joined(&lines); | |
| 376 | + | ||
| 377 | + | let parent_row = lines.iter().position(|l| l.contains("b (prod-1)")).unwrap(); | |
| 378 | + | let child_row = lines.iter().position(|l| l.contains("prod-1 (")).unwrap(); | |
| 379 | + | assert!(parent_row < child_row, "{text}"); | |
| 380 | + | // The child is indented relative to its parent. | |
| 381 | + | let parent_col = lines[parent_row].find("b (prod-1)").unwrap(); | |
| 382 | + | let child_col = lines[child_row].find("prod-1").unwrap(); | |
| 383 | + | assert!(child_col > parent_col, "child must be indented:\n{text}"); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | #[test] | |
| 387 | + | fn the_detail_pane_shows_conditions_with_their_why() { | |
| 388 | + | let mut n = node("tier:b", "b", Status::Ok); | |
| 389 | + | n.conditions = vec![Condition { | |
| 390 | + | condition_type: "burn_in".into(), | |
| 391 | + | status: Status::Pending, | |
| 392 | + | since: None, | |
| 393 | + | detail: Some("17 hours remaining of 48".into()), | |
| 394 | + | }]; | |
| 395 | + | let mut model = Model::new(vec![source("sando", now(), vec![n])]); | |
| 396 | + | model.select_tab(1); | |
| 397 | + | let text = joined(&draw(&model, now(), 80, 20)); | |
| 398 | + | ||
| 399 | + | assert!(text.contains("burn_in"), "{text}"); | |
| 400 | + | assert!( | |
| 401 | + | text.contains("17 hours remaining"), | |
| 402 | + | "a condition without its why is useless:\n{text}" | |
| 403 | + | ); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | #[test] | |
| 407 | + | fn a_progress_field_renders_as_a_bar() { | |
| 408 | + | let mut n = node("tier:b", "b", Status::Ok); | |
| 409 | + | n.fields = vec![Field::new( | |
| 410 | + | "burn-in", | |
| 411 | + | Value::Progress { | |
| 412 | + | value: 31.0, | |
| 413 | + | max: 48.0, | |
| 414 | + | unit: Some("hour".into()), | |
| 415 | + | }, | |
| 416 | + | )]; | |
| 417 | + | let mut model = Model::new(vec![source("sando", now(), vec![n])]); | |
| 418 | + | model.select_tab(1); | |
| 419 | + | let text = joined(&draw(&model, now(), 80, 20)); | |
| 420 | + | ||
| 421 | + | assert!(text.contains("31/48 hour"), "{text}"); | |
| 422 | + | assert!( | |
| 423 | + | text.contains('#'), | |
| 424 | + | "a progress value must draw a bar:\n{text}" | |
| 425 | + | ); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | #[test] | |
| 429 | + | fn an_instant_renders_relative_to_the_passed_in_clock() { | |
| 430 | + | let mut n = node("tier:b", "b", Status::Ok); | |
| 431 | + | n.fields = vec![Field::new( | |
| 432 | + | "built", | |
| 433 | + | Value::Instant { | |
| 434 | + | value: now() - TimeDelta::minutes(3), | |
| 435 | + | }, | |
| 436 | + | )]; | |
| 437 | + | let mut model = Model::new(vec![source("sando", now(), vec![n])]); | |
| 438 | + | model.select_tab(1); | |
| 439 | + | let text = joined(&draw(&model, now(), 80, 20)); | |
| 440 | + | assert!(text.contains("3m 0s ago"), "{text}"); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 444 | + | fn render_is_deterministic_for_a_fixed_clock() { | |
| 445 | + | // The property every snapshot test rests on. | |
| 446 | + | let mut n = node("tier:b", "b", Status::Ok); | |
| 447 | + | n.fields = vec![Field::new( | |
| 448 | + | "built", | |
| 449 | + | Value::Instant { | |
| 450 | + | value: now() - TimeDelta::minutes(3), | |
| 451 | + | }, | |
| 452 | + | )]; | |
| 453 | + | let mut model = Model::new(vec![source("sando", now(), vec![n])]); | |
| 454 | + | model.select_tab(1); | |
| 455 | + | assert_eq!(draw(&model, now(), 80, 20), draw(&model, now(), 80, 20)); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | #[test] | |
| 459 | + | fn an_unknown_value_kind_still_renders_as_text() { | |
| 460 | + | // Version skew: a producer one release ahead must not blank the pane. | |
| 461 | + | let field: Field = | |
| 462 | + | serde_json::from_str(r#"{"label":"temp","kind":"celsius","value":"41"}"#).unwrap(); | |
| 463 | + | let mut n = node("tier:b", "b", Status::Ok); | |
| 464 | + | n.fields = vec![field]; | |
| 465 | + | let mut model = Model::new(vec![source("sando", now(), vec![n])]); | |
| 466 | + | model.select_tab(1); | |
| 467 | + | let text = joined(&draw(&model, now(), 80, 20)); | |
| 468 | + | assert!(text.contains("temp"), "{text}"); | |
| 469 | + | assert!(text.contains("41"), "{text}"); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | #[test] | |
| 473 | + | fn a_narrow_terminal_does_not_panic() { | |
| 474 | + | // Every widget here has to survive a width no layout was designed for. | |
| 475 | + | let mut n = node("tier:b", "a rather long tier label", Status::Failed); | |
| 476 | + | n.fields = vec![Field::new( | |
| 477 | + | "path", | |
| 478 | + | Value::Path { | |
| 479 | + | value: "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork".into(), | |
| 480 | + | }, | |
| 481 | + | )]; | |
| 482 | + | n.conditions = vec![Condition { | |
| 483 | + | condition_type: "node_health".into(), | |
| 484 | + | status: Status::Failed, | |
| 485 | + | since: None, | |
| 486 | + | detail: Some("prod-1 unhealthy: connection refused after 30s".into()), | |
| 487 | + | }]; | |
| 488 | + | let mut model = Model::new(vec![source("sando", now(), vec![n])]); | |
| 489 | + | model.select_tab(1); | |
| 490 | + | for width in [8_u16, 12, 20, 40] { | |
| 491 | + | for height in [4_u16, 8, 20] { | |
| 492 | + | let _ = draw(&model, now(), width, height); | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | #[test] | |
| 498 | + | fn a_multiline_detail_is_flattened_not_sprawled() { | |
| 499 | + | assert_eq!(truncate("a\nb", 40), "a b"); | |
| 500 | + | assert!(truncate(&"x".repeat(100), 10).ends_with('…')); |
Lines truncated
| @@ -0,0 +1,338 @@ | |||
| 1 | + | //! Turning the ten value kinds into something on screen. | |
| 2 | + | //! | |
| 3 | + | //! This module is the entire reason producers are forbidden from formatting | |
| 4 | + | //! their own values. Every `progress` in the UI gets the same bar, every | |
| 5 | + | //! `instant` the same relative phrasing, every `ident` the same truncation — | |
| 6 | + | //! and consistency survives a new producer written a year from now by someone | |
| 7 | + | //! who never read this file, because the producer never gets a say. | |
| 8 | + | //! | |
| 9 | + | //! Everything here is a pure function. `instant` takes `now` as an argument | |
| 10 | + | //! rather than reading the clock, which is what keeps the whole render | |
| 11 | + | //! snapshot-testable. | |
| 12 | + | ||
| 13 | + | use chrono::{DateTime, Utc}; | |
| 14 | + | use ops_status::{Status, Value}; | |
| 15 | + | use ratatui::style::{Color, Modifier, Style}; | |
| 16 | + | ||
| 17 | + | /// The one place a status becomes a color. | |
| 18 | + | pub fn status_style(status: Status) -> Style { | |
| 19 | + | match status { | |
| 20 | + | Status::Ok => Style::default().fg(Color::Green), | |
| 21 | + | Status::Degraded => Style::default().fg(Color::Yellow), | |
| 22 | + | Status::Failed => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), | |
| 23 | + | Status::Pending => Style::default().fg(Color::Blue), | |
| 24 | + | // Magenta, not gray: an unreachable source is not a quiet absence, it | |
| 25 | + | // is the loudest thing a viewer can fail to tell you. | |
| 26 | + | Status::Unknown => Style::default().fg(Color::Magenta), | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// A short, fixed-width-ish marker so columns line up. | |
| 31 | + | pub fn status_mark(status: Status) -> &'static str { | |
| 32 | + | match status { | |
| 33 | + | Status::Ok => "ok", | |
| 34 | + | Status::Degraded => "degr", | |
| 35 | + | Status::Failed => "FAIL", | |
| 36 | + | Status::Pending => "pend", | |
| 37 | + | Status::Unknown => "????", | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// Render one value. `width` is the budget for things that elide. | |
| 42 | + | pub fn render(value: &Value, now: DateTime<Utc>, width: usize) -> String { | |
| 43 | + | match value { | |
| 44 | + | Value::Text { value } => value.clone(), | |
| 45 | + | Value::Ident { value, abbrev_to } => abbreviate(value, abbrev_to.unwrap_or(12)), | |
| 46 | + | Value::Version { value } => value.clone(), | |
| 47 | + | Value::Instant { value } => relative(*value, now), | |
| 48 | + | Value::Duration { seconds } => duration(*seconds), | |
| 49 | + | Value::Progress { value, max, unit } => progress(*value, *max, unit.as_deref(), width), | |
| 50 | + | Value::Quantity { value, unit } => quantity(*value, unit.as_deref()), | |
| 51 | + | Value::State { value } => status_mark(*value).to_string(), | |
| 52 | + | Value::Link { url, text } => text.clone().unwrap_or_else(|| url.clone()), | |
| 53 | + | Value::Path { value } => middle_elide(value, width.max(12)), | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// The style a value carries on its own, if any. | |
| 58 | + | pub fn style(value: &Value) -> Style { | |
| 59 | + | match value { | |
| 60 | + | Value::State { value } => status_style(*value), | |
| 61 | + | Value::Ident { .. } | Value::Path { .. } => Style::default().fg(Color::Cyan), | |
| 62 | + | Value::Link { .. } => Style::default() | |
| 63 | + | .fg(Color::Cyan) | |
| 64 | + | .add_modifier(Modifier::UNDERLINED), | |
| 65 | + | _ => Style::default(), | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Truncate an identifier at a character boundary. | |
| 70 | + | /// | |
| 71 | + | /// Char-wise rather than byte-wise: a digest is ASCII, but nothing in the | |
| 72 | + | /// contract promises that, and slicing a multi-byte value at a byte index | |
| 73 | + | /// panics. | |
| 74 | + | pub fn abbreviate(value: &str, to: usize) -> String { | |
| 75 | + | if value.chars().count() <= to { | |
| 76 | + | return value.to_string(); | |
| 77 | + | } | |
| 78 | + | value.chars().take(to).collect() | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Elide the middle of a path, keeping both ends: the leading directories say | |
| 82 | + | /// where you are and the basename says what it is. Truncating either end alone | |
| 83 | + | /// throws away the half you needed. | |
| 84 | + | pub fn middle_elide(value: &str, max: usize) -> String { | |
| 85 | + | let chars: Vec<char> = value.chars().collect(); | |
| 86 | + | if chars.len() <= max { | |
| 87 | + | return value.to_string(); | |
| 88 | + | } | |
| 89 | + | if max <= 3 { | |
| 90 | + | return "...".into(); | |
| 91 | + | } | |
| 92 | + | let keep = max - 3; | |
| 93 | + | let head = keep.div_ceil(2); | |
| 94 | + | let tail = keep - head; | |
| 95 | + | let mut out: String = chars[..head].iter().collect(); | |
| 96 | + | out.push_str("..."); | |
| 97 | + | out.extend(&chars[chars.len() - tail..]); | |
| 98 | + | out | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// "3m ago", "in 2m", "just now". | |
| 102 | + | pub fn relative(at: DateTime<Utc>, now: DateTime<Utc>) -> String { | |
| 103 | + | let delta = now - at; | |
| 104 | + | let secs = delta.num_seconds(); | |
| 105 | + | if secs.abs() < 5 { | |
| 106 | + | return "just now".into(); | |
| 107 | + | } | |
| 108 | + | let magnitude = duration(secs.abs()); | |
| 109 | + | if secs > 0 { | |
| 110 | + | format!("{magnitude} ago") | |
| 111 | + | } else { | |
| 112 | + | format!("in {magnitude}") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Humanize a span. Two units at most: "2h 14m" is useful, "2h 14m 7s" is | |
| 117 | + | /// noise at every scale where hours matter. | |
| 118 | + | pub fn duration(seconds: i64) -> String { | |
| 119 | + | let s = seconds.abs(); | |
| 120 | + | let (d, h, m, sec) = (s / 86_400, (s % 86_400) / 3600, (s % 3600) / 60, s % 60); | |
| 121 | + | match (d, h, m) { | |
| 122 | + | (0, 0, 0) => format!("{sec}s"), | |
| 123 | + | (0, 0, _) => format!("{m}m {sec}s"), | |
| 124 | + | (0, _, 0) => format!("{h}h"), | |
| 125 | + | (0, _, _) => format!("{h}h {m}m"), | |
| 126 | + | (_, 0, _) => format!("{d}d"), | |
| 127 | + | (_, _, _) => format!("{d}d {h}h"), | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// A magnitude with its unit. The number is humanized; the unit is the | |
| 132 | + | /// producer's, verbatim. | |
| 133 | + | pub fn quantity(value: f64, unit: Option<&str>) -> String { | |
| 134 | + | let n = humanize_number(value); | |
| 135 | + | match unit { | |
| 136 | + | Some(u) => format!("{n} {u}"), | |
| 137 | + | None => n, | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn humanize_number(value: f64) -> String { | |
| 142 | + | let abs = value.abs(); | |
| 143 | + | let (scaled, suffix) = if abs >= 1e9 { | |
| 144 | + | (value / 1e9, "G") | |
| 145 | + | } else if abs >= 1e6 { | |
| 146 | + | (value / 1e6, "M") | |
| 147 | + | } else if abs >= 1_000.0 { | |
| 148 | + | (value / 1_000.0, "k") | |
| 149 | + | } else { | |
| 150 | + | (value, "") | |
| 151 | + | }; | |
| 152 | + | let rendered = if scaled.fract().abs() < 0.05 { | |
| 153 | + | format!("{:.0}", scaled) | |
| 154 | + | } else { | |
| 155 | + | format!("{:.1}", scaled) | |
| 156 | + | }; | |
| 157 | + | format!("{rendered}{suffix}") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// A progress bar plus its numbers, e.g. `[####------] 31/48 hour`. | |
| 161 | + | pub fn progress(value: f64, max: f64, unit: Option<&str>, width: usize) -> String { | |
| 162 | + | let numbers = match unit { | |
| 163 | + | Some(u) => format!("{}/{} {u}", humanize_number(value), humanize_number(max)), | |
| 164 | + | None => format!("{}/{}", humanize_number(value), humanize_number(max)), | |
| 165 | + | }; | |
| 166 | + | ||
| 167 | + | // The bar is whatever is left after the numbers, floored at something still | |
| 168 | + | // readable and capped so it does not sprawl across a wide terminal. | |
| 169 | + | let bar_width = width.saturating_sub(numbers.len() + 3).clamp(4, 24); | |
| 170 | + | let fraction = if max > 0.0 { | |
| 171 | + | (value / max).clamp(0.0, 1.0) | |
| 172 | + | } else { | |
| 173 | + | 0.0 | |
| 174 | + | }; | |
| 175 | + | let filled = (fraction * bar_width as f64).round() as usize; | |
| 176 | + | let bar: String = "#".repeat(filled) + &"-".repeat(bar_width - filled); | |
| 177 | + | format!("[{bar}] {numbers}") | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// How a progress value should be colored: complete is done, not merely far | |
| 181 | + | /// along. | |
| 182 | + | pub fn progress_style(value: f64, max: f64) -> Style { | |
| 183 | + | if max > 0.0 && value >= max { | |
| 184 | + | Style::default().fg(Color::Green) | |
| 185 | + | } else { | |
| 186 | + | Style::default().fg(Color::Blue) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[cfg(test)] | |
| 191 | + | mod tests { | |
| 192 | + | use super::*; | |
| 193 | + | use chrono::TimeDelta; | |
| 194 | + | ||
| 195 | + | fn now() -> DateTime<Utc> { | |
| 196 | + | "2026-07-21T18:00:00Z".parse().unwrap() | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | #[test] | |
| 200 | + | fn durations_stop_at_two_units() { | |
| 201 | + | assert_eq!(duration(45), "45s"); | |
| 202 | + | assert_eq!(duration(125), "2m 5s"); | |
| 203 | + | assert_eq!(duration(7200), "2h"); | |
| 204 | + | assert_eq!(duration(8040), "2h 14m"); | |
| 205 | + | assert_eq!(duration(172_800), "2d"); | |
| 206 | + | assert_eq!(duration(180_000), "2d 2h"); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn relative_time_reads_in_both_directions() { | |
| 211 | + | let base = now(); | |
| 212 | + | assert_eq!(relative(base, base), "just now"); | |
| 213 | + | assert_eq!(relative(base - TimeDelta::minutes(3), base), "3m 0s ago"); | |
| 214 | + | // A producer whose clock runs ahead must not render as a negative age. | |
| 215 | + | assert_eq!(relative(base + TimeDelta::minutes(2), base), "in 2m 0s"); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[test] | |
| 219 | + | fn identifiers_truncate_at_a_character_boundary() { | |
| 220 | + | assert_eq!(abbreviate("a3f9c21b7e4d8056", 8), "a3f9c21b"); | |
| 221 | + | assert_eq!(abbreviate("short", 8), "short"); | |
| 222 | + | // Multi-byte: byte slicing here would panic. | |
| 223 | + | assert_eq!(abbreviate("ααααββββ", 4), "αααα"); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | #[test] | |
| 227 | + | fn paths_keep_both_ends() { | |
| 228 | + | let path = "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork"; | |
| 229 | + | let out = middle_elide(path, 30); | |
| 230 | + | assert_eq!(out.chars().count(), 30); | |
| 231 | + | assert!(out.starts_with("/srv/sando"), "{out}"); | |
| 232 | + | assert!(out.ends_with("makenotwork"), "{out}"); | |
| 233 | + | assert!(out.contains("...")); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn a_short_path_is_left_alone() { | |
| 238 | + | assert_eq!(middle_elide("/etc/sando", 30), "/etc/sando"); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn quantities_humanize_the_number_and_keep_the_producers_unit() { | |
| 243 | + | assert_eq!(quantity(43.5, Some("MB")), "43.5 MB"); | |
| 244 | + | assert_eq!(quantity(1_200.0, None), "1.2k"); | |
| 245 | + | assert_eq!(quantity(2_000_000.0, None), "2M"); | |
| 246 | + | assert_eq!(quantity(7.0, Some("node")), "7 node"); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[test] | |
| 250 | + | fn progress_renders_a_bar_and_its_numbers() { | |
| 251 | + | let out = progress(31.0, 48.0, Some("hour"), 40); | |
| 252 | + | assert!(out.contains("31/48 hour"), "{out}"); | |
| 253 | + | assert!(out.starts_with('['), "{out}"); | |
| 254 | + | assert!(out.contains('#') && out.contains('-'), "{out}"); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[test] | |
| 258 | + | fn progress_survives_nonsense_without_panicking() { | |
| 259 | + | // A producer bug must not take the viewer down. | |
| 260 | + | assert!(progress(5.0, 0.0, None, 40).contains("5/0")); | |
| 261 | + | assert!(progress(-1.0, 10.0, None, 40).starts_with('[')); | |
| 262 | + | assert!(progress(99.0, 10.0, None, 40).starts_with('[')); | |
| 263 | + | // Width smaller than the numbers still yields a bar, not a panic. | |
| 264 | + | assert!(progress(31.0, 48.0, Some("hour"), 1).starts_with('[')); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn a_complete_bar_reads_as_done() { | |
| 269 | + | assert_eq!( | |
| 270 | + | progress_style(48.0, 48.0), | |
| 271 | + | Style::default().fg(Color::Green) | |
| 272 | + | ); | |
| 273 | + | assert_eq!(progress_style(31.0, 48.0), Style::default().fg(Color::Blue)); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn every_kind_renders_to_something_non_empty() { | |
| 278 | + | let values = vec![ | |
| 279 | + | Value::Text { value: "x".into() }, | |
| 280 | + | Value::Ident { | |
| 281 | + | value: "a3f9c21b7e4d8056".into(), | |
| 282 | + | abbrev_to: Some(8), | |
| 283 | + | }, | |
| 284 | + | Value::Version { | |
| 285 | + | value: "0.10.14".into(), | |
| 286 | + | }, | |
| 287 | + | Value::Instant { | |
| 288 | + | value: now() - TimeDelta::minutes(3), | |
| 289 | + | }, | |
| 290 | + | Value::Duration { seconds: 3600 }, | |
| 291 | + | Value::Progress { | |
| 292 | + | value: 31.0, | |
| 293 | + | max: 48.0, | |
| 294 | + | unit: Some("hour".into()), | |
| 295 | + | }, | |
| 296 | + | Value::Quantity { | |
| 297 | + | value: 43.5, | |
| 298 | + | unit: Some("MB".into()), | |
| 299 | + | }, | |
| 300 | + | Value::State { | |
| 301 | + | value: Status::Degraded, | |
| 302 | + | }, | |
| 303 | + | Value::Link { | |
| 304 | + | url: "https://makenot.work".into(), | |
| 305 | + | text: None, | |
| 306 | + | }, | |
| 307 | + | Value::Path { | |
| 308 | + | value: "/srv/sando".into(), | |
| 309 | + | }, | |
| 310 | + | ]; | |
| 311 | + | for value in values { | |
| 312 | + | let out = render(&value, now(), 40); | |
| 313 | + | assert!(!out.is_empty(), "{} rendered empty", value.kind()); | |
| 314 | + | assert!(!out.contains('\n'), "{} rendered multi-line", value.kind()); | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[test] | |
| 319 | + | fn a_link_prefers_its_text_and_falls_back_to_the_url() { | |
| 320 | + | let with = Value::Link { | |
| 321 | + | url: "https://makenot.work".into(), | |
| 322 | + | text: Some("site".into()), | |
| 323 | + | }; | |
| 324 | + | assert_eq!(render(&with, now(), 40), "site"); | |
| 325 | + | let without = Value::Link { | |
| 326 | + | url: "https://makenot.work".into(), | |
| 327 | + | text: None, | |
| 328 | + | }; | |
| 329 | + | assert_eq!(render(&without, now(), 40), "https://makenot.work"); | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | #[test] | |
| 333 | + | fn an_unknown_status_is_loud_not_gray() { | |
| 334 | + | // A source nobody can reach must not read as a quiet absence. | |
| 335 | + | assert_eq!(status_style(Status::Unknown).fg, Some(Color::Magenta)); | |
| 336 | + | assert_eq!(status_style(Status::Ok).fg, Some(Color::Green)); | |
| 337 | + | } | |
| 338 | + | } |