Skip to main content

max / makenotwork

Add the viewer: one surface over every status.json source Tabs per source plus a rollup showing every source at once, worst first. The rollup is the product: without it this is N tabs you still have to visit one at a time, which is the situation it replaces with extra steps. The viewer knows nothing about tiers, gates, apps or targets. It renders ops-status and nothing else, so a new daemon gets a UI by emitting a payload and adding four lines of config. The moment a source needs a special case in here, the shell has started learning domain vocabulary. Read-only for now. The declared actions are shown by name and the payload carries everything needed to fire them, but no key issues a request. The actions on the sando tab include promote-b and rollback-b, which move production; a surface that can fire those from a keystroke deserves its own deliberate pass rather than arriving as a side effect of building the display. Wiring it is a confirm modal and a POST. Three things the contract's design paid for here: Render is a pure function of (model, now), with the clock threaded through every call rather than read ambiently. That is what lets the whole surface be snapshot-tested against fixture payloads through TestBackend with no daemon running and no terminal attached. Staleness is the viewer's job, not the producer's. A source cannot mark itself stale because the case that matters is the one where it stopped answering entirely. A source that answers with something old reads as degraded however green its contents, which is the 40-day backup that every check called healthy. Source reachability is a status. A source never polled is unknown and sorts above degraded, and an unreachable one keeps its last payload on screen next to the reason it could not be refreshed, rather than going blank. Tokens are named, never pasted: token_env points at the same environment variable the daemon already reads, so the config file describing topology stays readable without holding a prod token. ratatui 0.30.2, using its own crossterm re-export so the two cannot drift into a version mismatch. Both TUIs' pending 0.29-to-0.30 upgrade tasks were closed as superseded; they are being deleted, not upgraded. sando/tui and bento/tui are still in the tree. They should go once this has been run against live daemons. The product also has no name yet -- ops-viewer is a placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 23:38 UTC
Commit: 29ec282a344ef49b7aa15f4823ef0f60220c5e4e
Parent: be58b6f
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 + }