Skip to main content

max / mountaineer

Add 'sysop/' from commit '0612c9431dee54b56e9d7a3c7bbc2d14f083d94b' git-subtree-dir: sysop git-subtree-mainline: 2b96943159debad8446549bff833e94c4f89003a git-subtree-split: 0612c9431dee54b56e9d7a3c7bbc2d14f083d94b
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-18 03:11 UTC
Commit: 5dc41a4d0ff0941e31ebd86d837bc8e0a20c0200
Parents: 2b96943, 0612c94
24 files changed, +2548 insertions, -0 deletions
@@ -0,0 +1,4 @@
1 + /target
2 + **/*.rs.bk
3 + Cargo.lock.bak
4 + .DS_Store
@@ -0,0 +1,865 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "allocator-api2"
7 + version = "0.2.21"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
10 +
11 + [[package]]
12 + name = "anstream"
13 + version = "1.0.0"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
16 + dependencies = [
17 + "anstyle",
18 + "anstyle-parse",
19 + "anstyle-query",
20 + "anstyle-wincon",
21 + "colorchoice",
22 + "is_terminal_polyfill",
23 + "utf8parse",
24 + ]
25 +
26 + [[package]]
27 + name = "anstyle"
28 + version = "1.0.14"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
31 +
32 + [[package]]
33 + name = "anstyle-parse"
34 + version = "1.0.0"
35 + source = "registry+https://github.com/rust-lang/crates.io-index"
36 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
37 + dependencies = [
38 + "utf8parse",
39 + ]
40 +
41 + [[package]]
42 + name = "anstyle-query"
43 + version = "1.1.5"
44 + source = "registry+https://github.com/rust-lang/crates.io-index"
45 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
46 + dependencies = [
47 + "windows-sys 0.61.2",
48 + ]
49 +
50 + [[package]]
51 + name = "anstyle-wincon"
52 + version = "3.0.11"
53 + source = "registry+https://github.com/rust-lang/crates.io-index"
54 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
55 + dependencies = [
56 + "anstyle",
57 + "once_cell_polyfill",
58 + "windows-sys 0.61.2",
59 + ]
60 +
61 + [[package]]
62 + name = "anyhow"
63 + version = "1.0.102"
64 + source = "registry+https://github.com/rust-lang/crates.io-index"
65 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
66 +
67 + [[package]]
68 + name = "bitflags"
69 + version = "2.11.1"
70 + source = "registry+https://github.com/rust-lang/crates.io-index"
71 + checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
72 +
73 + [[package]]
74 + name = "cassowary"
75 + version = "0.3.0"
76 + source = "registry+https://github.com/rust-lang/crates.io-index"
77 + checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
78 +
79 + [[package]]
80 + name = "castaway"
81 + version = "0.2.4"
82 + source = "registry+https://github.com/rust-lang/crates.io-index"
83 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
84 + dependencies = [
85 + "rustversion",
86 + ]
87 +
88 + [[package]]
89 + name = "cfg-if"
90 + version = "1.0.4"
91 + source = "registry+https://github.com/rust-lang/crates.io-index"
92 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
93 +
94 + [[package]]
95 + name = "clap"
96 + version = "4.6.1"
97 + source = "registry+https://github.com/rust-lang/crates.io-index"
98 + checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
99 + dependencies = [
100 + "clap_builder",
101 + "clap_derive",
102 + ]
103 +
104 + [[package]]
105 + name = "clap_builder"
106 + version = "4.6.0"
107 + source = "registry+https://github.com/rust-lang/crates.io-index"
108 + checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
109 + dependencies = [
110 + "anstream",
111 + "anstyle",
112 + "clap_lex",
113 + "strsim",
114 + ]
115 +
116 + [[package]]
117 + name = "clap_derive"
118 + version = "4.6.1"
119 + source = "registry+https://github.com/rust-lang/crates.io-index"
120 + checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
121 + dependencies = [
122 + "heck",
123 + "proc-macro2",
124 + "quote",
125 + "syn",
126 + ]
127 +
128 + [[package]]
129 + name = "clap_lex"
130 + version = "1.1.0"
131 + source = "registry+https://github.com/rust-lang/crates.io-index"
132 + checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
133 +
134 + [[package]]
135 + name = "colorchoice"
136 + version = "1.0.5"
137 + source = "registry+https://github.com/rust-lang/crates.io-index"
138 + checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
139 +
140 + [[package]]
141 + name = "compact_str"
142 + version = "0.8.1"
143 + source = "registry+https://github.com/rust-lang/crates.io-index"
144 + checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
145 + dependencies = [
146 + "castaway",
147 + "cfg-if",
148 + "itoa",
149 + "rustversion",
150 + "ryu",
151 + "static_assertions",
152 + ]
153 +
154 + [[package]]
155 + name = "crossterm"
156 + version = "0.28.1"
157 + source = "registry+https://github.com/rust-lang/crates.io-index"
158 + checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
159 + dependencies = [
160 + "bitflags",
161 + "crossterm_winapi",
162 + "mio",
163 + "parking_lot",
164 + "rustix",
165 + "signal-hook",
166 + "signal-hook-mio",
167 + "winapi",
168 + ]
169 +
170 + [[package]]
171 + name = "crossterm_winapi"
172 + version = "0.9.1"
173 + source = "registry+https://github.com/rust-lang/crates.io-index"
174 + checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
175 + dependencies = [
176 + "winapi",
177 + ]
178 +
179 + [[package]]
180 + name = "darling"
181 + version = "0.20.11"
182 + source = "registry+https://github.com/rust-lang/crates.io-index"
183 + checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
184 + dependencies = [
185 + "darling_core",
186 + "darling_macro",
187 + ]
188 +
189 + [[package]]
190 + name = "darling_core"
191 + version = "0.20.11"
192 + source = "registry+https://github.com/rust-lang/crates.io-index"
193 + checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
194 + dependencies = [
195 + "fnv",
196 + "ident_case",
197 + "proc-macro2",
198 + "quote",
199 + "strsim",
200 + "syn",
201 + ]
202 +
203 + [[package]]
204 + name = "darling_macro"
205 + version = "0.20.11"
206 + source = "registry+https://github.com/rust-lang/crates.io-index"
207 + checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
208 + dependencies = [
209 + "darling_core",
210 + "quote",
211 + "syn",
212 + ]
213 +
214 + [[package]]
215 + name = "either"
216 + version = "1.15.0"
217 + source = "registry+https://github.com/rust-lang/crates.io-index"
218 + checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
219 +
220 + [[package]]
221 + name = "equivalent"
222 + version = "1.0.2"
223 + source = "registry+https://github.com/rust-lang/crates.io-index"
224 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
225 +
226 + [[package]]
227 + name = "errno"
228 + version = "0.3.14"
229 + source = "registry+https://github.com/rust-lang/crates.io-index"
230 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
231 + dependencies = [
232 + "libc",
233 + "windows-sys 0.61.2",
234 + ]
235 +
236 + [[package]]
237 + name = "fnv"
238 + version = "1.0.7"
239 + source = "registry+https://github.com/rust-lang/crates.io-index"
240 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
241 +
242 + [[package]]
243 + name = "foldhash"
244 + version = "0.1.5"
245 + source = "registry+https://github.com/rust-lang/crates.io-index"
246 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
247 +
248 + [[package]]
249 + name = "hashbrown"
250 + version = "0.15.5"
251 + source = "registry+https://github.com/rust-lang/crates.io-index"
252 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
253 + dependencies = [
254 + "allocator-api2",
255 + "equivalent",
256 + "foldhash",
257 + ]
258 +
259 + [[package]]
260 + name = "hashbrown"
261 + version = "0.17.1"
262 + source = "registry+https://github.com/rust-lang/crates.io-index"
263 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
264 +
265 + [[package]]
266 + name = "heck"
267 + version = "0.5.0"
268 + source = "registry+https://github.com/rust-lang/crates.io-index"
269 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
270 +
271 + [[package]]
272 + name = "ident_case"
273 + version = "1.0.1"
274 + source = "registry+https://github.com/rust-lang/crates.io-index"
275 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
276 +
277 + [[package]]
278 + name = "indexmap"
279 + version = "2.14.0"
280 + source = "registry+https://github.com/rust-lang/crates.io-index"
281 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
282 + dependencies = [
283 + "equivalent",
284 + "hashbrown 0.17.1",
285 + ]
286 +
287 + [[package]]
288 + name = "indoc"
289 + version = "2.0.7"
290 + source = "registry+https://github.com/rust-lang/crates.io-index"
291 + checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
292 + dependencies = [
293 + "rustversion",
294 + ]
295 +
296 + [[package]]
297 + name = "instability"
298 + version = "0.3.10"
299 + source = "registry+https://github.com/rust-lang/crates.io-index"
300 + checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c"
301 + dependencies = [
302 + "darling",
303 + "indoc",
304 + "proc-macro2",
305 + "quote",
306 + "syn",
307 + ]
308 +
309 + [[package]]
310 + name = "is_terminal_polyfill"
311 + version = "1.70.2"
312 + source = "registry+https://github.com/rust-lang/crates.io-index"
313 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
314 +
315 + [[package]]
316 + name = "itertools"
317 + version = "0.13.0"
318 + source = "registry+https://github.com/rust-lang/crates.io-index"
319 + checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
320 + dependencies = [
321 + "either",
322 + ]
323 +
324 + [[package]]
325 + name = "itoa"
326 + version = "1.0.18"
327 + source = "registry+https://github.com/rust-lang/crates.io-index"
328 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
329 +
330 + [[package]]
331 + name = "libc"
332 + version = "0.2.186"
333 + source = "registry+https://github.com/rust-lang/crates.io-index"
334 + checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
335 +
336 + [[package]]
337 + name = "linux-raw-sys"
338 + version = "0.4.15"
339 + source = "registry+https://github.com/rust-lang/crates.io-index"
340 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
341 +
342 + [[package]]
343 + name = "lock_api"
344 + version = "0.4.14"
345 + source = "registry+https://github.com/rust-lang/crates.io-index"
346 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
347 + dependencies = [
348 + "scopeguard",
349 + ]
350 +
351 + [[package]]
352 + name = "log"
353 + version = "0.4.29"
354 + source = "registry+https://github.com/rust-lang/crates.io-index"
355 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
356 +
357 + [[package]]
358 + name = "lru"
359 + version = "0.12.5"
360 + source = "registry+https://github.com/rust-lang/crates.io-index"
361 + checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
362 + dependencies = [
363 + "hashbrown 0.15.5",
364 + ]
365 +
366 + [[package]]
367 + name = "memchr"
368 + version = "2.8.0"
369 + source = "registry+https://github.com/rust-lang/crates.io-index"
370 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
371 +
372 + [[package]]
373 + name = "mio"
374 + version = "1.2.0"
375 + source = "registry+https://github.com/rust-lang/crates.io-index"
376 + checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
377 + dependencies = [
378 + "libc",
379 + "log",
380 + "wasi",
381 + "windows-sys 0.61.2",
382 + ]
383 +
384 + [[package]]
385 + name = "once_cell_polyfill"
386 + version = "1.70.2"
387 + source = "registry+https://github.com/rust-lang/crates.io-index"
388 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
389 +
390 + [[package]]
391 + name = "parking_lot"
392 + version = "0.12.5"
393 + source = "registry+https://github.com/rust-lang/crates.io-index"
394 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
395 + dependencies = [
396 + "lock_api",
397 + "parking_lot_core",
398 + ]
399 +
400 + [[package]]
401 + name = "parking_lot_core"
402 + version = "0.9.12"
403 + source = "registry+https://github.com/rust-lang/crates.io-index"
404 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
405 + dependencies = [
406 + "cfg-if",
407 + "libc",
408 + "redox_syscall",
409 + "smallvec",
410 + "windows-link",
411 + ]
412 +
413 + [[package]]
414 + name = "paste"
415 + version = "1.0.15"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
418 +
419 + [[package]]
420 + name = "proc-macro2"
421 + version = "1.0.106"
422 + source = "registry+https://github.com/rust-lang/crates.io-index"
423 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
424 + dependencies = [
425 + "unicode-ident",
426 + ]
427 +
428 + [[package]]
429 + name = "quote"
430 + version = "1.0.45"
431 + source = "registry+https://github.com/rust-lang/crates.io-index"
432 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
433 + dependencies = [
434 + "proc-macro2",
435 + ]
436 +
437 + [[package]]
438 + name = "ratatui"
439 + version = "0.29.0"
440 + source = "registry+https://github.com/rust-lang/crates.io-index"
441 + checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
442 + dependencies = [
443 + "bitflags",
444 + "cassowary",
445 + "compact_str",
446 + "crossterm",
447 + "indoc",
448 + "instability",
449 + "itertools",
450 + "lru",
451 + "paste",
452 + "strum",
453 + "unicode-segmentation",
454 + "unicode-truncate",
455 + "unicode-width 0.2.0",
456 + ]
457 +
458 + [[package]]
459 + name = "redox_syscall"
460 + version = "0.5.18"
461 + source = "registry+https://github.com/rust-lang/crates.io-index"
462 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
463 + dependencies = [
464 + "bitflags",
465 + ]
466 +
467 + [[package]]
468 + name = "rustix"
469 + version = "0.38.44"
470 + source = "registry+https://github.com/rust-lang/crates.io-index"
471 + checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
472 + dependencies = [
473 + "bitflags",
474 + "errno",
475 + "libc",
476 + "linux-raw-sys",
477 + "windows-sys 0.59.0",
478 + ]
479 +
480 + [[package]]
481 + name = "rustversion"
482 + version = "1.0.22"
483 + source = "registry+https://github.com/rust-lang/crates.io-index"
484 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
485 +
486 + [[package]]
487 + name = "ryu"
488 + version = "1.0.23"
489 + source = "registry+https://github.com/rust-lang/crates.io-index"
490 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
491 +
492 + [[package]]
493 + name = "scopeguard"
494 + version = "1.2.0"
495 + source = "registry+https://github.com/rust-lang/crates.io-index"
496 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
497 +
498 + [[package]]
499 + name = "serde"
500 + version = "1.0.228"
Lines truncated
@@ -0,0 +1,18 @@
1 + [workspace]
2 + resolver = "3"
3 + members = ["crates/sysop", "crates/sysop-tui"]
4 +
5 + [workspace.package]
6 + edition = "2024"
7 + rust-version = "1.85"
8 + license = "MIT OR Apache-2.0"
9 +
10 + [workspace.dependencies]
11 + sysop-tui = { path = "crates/sysop-tui" }
12 + ratatui = "0.29"
13 + crossterm = "0.28"
14 + anyhow = "1"
15 + clap = { version = "4", features = ["derive"] }
16 + serde = { version = "1", features = ["derive"] }
17 + serde_json = "1"
18 + toml = "0.8"
@@ -0,0 +1,10 @@
1 + [package]
2 + name = "sysop-tui"
3 + version = "0.1.0"
4 + edition.workspace = true
5 + rust-version.workspace = true
6 + license.workspace = true
7 +
8 + [dependencies]
9 + ratatui.workspace = true
10 + crossterm.workspace = true
@@ -0,0 +1,106 @@
1 + use crossterm::event::{self, Event, KeyCode};
2 + use ratatui::style::Style;
3 + use ratatui::text::{Line, Span};
4 + use ratatui::widgets::Paragraph;
5 + use std::io;
6 + use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
7 +
8 + enum State {
9 + Ok,
10 + Warn,
11 + Fault,
12 + Inactive,
13 + }
14 +
15 + struct Service {
16 + name: &'static str,
17 + state: State,
18 + }
19 +
20 + fn state_span(s: &State) -> Span<'static> {
21 + match s {
22 + State::Ok => style::ok("ok"),
23 + State::Warn => style::warn("syncing"),
24 + State::Fault => style::fault("down"),
25 + State::Inactive => style::inactive("stopped"),
26 + }
27 + }
28 +
29 + fn services() -> Vec<Service> {
30 + vec![
31 + Service { name: "sshd", state: State::Ok },
32 + Service { name: "unbound", state: State::Ok },
33 + Service { name: "chronyd", state: State::Ok },
34 + Service { name: "caddy", state: State::Warn },
35 + Service { name: "nebula", state: State::Fault },
36 + Service { name: "tlp", state: State::Inactive },
37 + ]
38 + }
39 +
40 + fn body(items: &[Service], selected: usize) -> Paragraph<'_> {
41 + let lines: Vec<Line> = items
42 + .iter()
43 + .enumerate()
44 + .map(|(i, s)| {
45 + let is_sel = i == selected;
46 + let marker = if is_sel { selection::MARKER } else { " " };
47 + let line = Line::from(vec![
48 + Span::raw(format!(" {} ", marker)),
49 + Span::raw(format!("{:<12}", s.name)),
50 + Span::raw(" "),
51 + state_span(&s.state),
52 + ]);
53 + if is_sel {
54 + line.style(selection::selected_style())
55 + } else {
56 + line
57 + }
58 + })
59 + .collect();
60 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
61 + }
62 +
63 + fn main() -> io::Result<()> {
64 + let mut terminal = ratatui::init();
65 + let services = services();
66 + let mut idx: usize = 0;
67 +
68 + let footer = Footer::new([
69 + hint("j/k", "move"),
70 + hint("Enter", "details"),
71 + hint("?", "help"),
72 + hint("q", "quit"),
73 + ]);
74 +
75 + let result: io::Result<()> = loop {
76 + let draw = terminal.draw(|frame| {
77 + let [body_area, foot_area] = layout::split(frame.area());
78 + frame.render_widget(body(&services, idx), body_area);
79 + frame.render_widget(&footer, foot_area);
80 + });
81 + if let Err(e) = draw {
82 + break Err(e);
83 + }
84 +
85 + match event::read() {
86 + Ok(Event::Key(key)) => match classify(key) {
87 + GlobalAction::Quit => break Ok(()),
88 + GlobalAction::Passthrough => match key.code {
89 + KeyCode::Char('j') | KeyCode::Down if idx + 1 < services.len() => {
90 + idx += 1;
91 + }
92 + KeyCode::Char('k') | KeyCode::Up => {
93 + idx = idx.saturating_sub(1);
94 + }
95 + _ => {}
96 + },
97 + _ => {}
98 + },
99 + Ok(_) => {}
100 + Err(e) => break Err(e),
101 + }
102 + };
103 +
104 + ratatui::restore();
105 + result
106 + }
@@ -0,0 +1,44 @@
1 + use crate::palette;
2 + use ratatui::buffer::Buffer;
3 + use ratatui::layout::Rect;
4 + use ratatui::style::Style;
5 + use ratatui::text::{Line, Span};
6 + use ratatui::widgets::{Paragraph, Widget};
7 +
8 + pub struct Hint {
9 + pub key: &'static str,
10 + pub label: &'static str,
11 + }
12 +
13 + pub fn hint(key: &'static str, label: &'static str) -> Hint {
14 + Hint { key, label }
15 + }
16 +
17 + pub struct Footer {
18 + hints: Vec<Hint>,
19 + }
20 +
21 + impl Footer {
22 + pub fn new(hints: impl IntoIterator<Item = Hint>) -> Self {
23 + Self {
24 + hints: hints.into_iter().collect(),
25 + }
26 + }
27 + }
28 +
29 + impl Widget for &Footer {
30 + fn render(self, area: Rect, buf: &mut Buffer) {
31 + let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 4);
32 + for (i, h) in self.hints.iter().enumerate() {
33 + if i > 0 {
34 + spans.push(Span::raw(" "));
35 + }
36 + spans.push(Span::styled(h.key, Style::default().fg(palette::ACCENT)));
37 + spans.push(Span::raw(" "));
38 + spans.push(Span::styled(h.label, Style::default().fg(palette::DIM)));
39 + }
40 + Paragraph::new(Line::from(spans))
41 + .style(Style::default().bg(palette::BG))
42 + .render(area, buf);
43 + }
44 + }
@@ -0,0 +1,22 @@
1 + use crossterm::event::{KeyCode, KeyEvent};
2 +
3 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4 + pub enum GlobalAction {
5 + Help,
6 + Quit,
7 + Back,
8 + Filter,
9 + Command,
10 + Passthrough,
11 + }
12 +
13 + pub fn classify(key: KeyEvent) -> GlobalAction {
14 + match key.code {
15 + KeyCode::Char('?') => GlobalAction::Help,
16 + KeyCode::Char('q') => GlobalAction::Quit,
17 + KeyCode::Esc => GlobalAction::Back,
18 + KeyCode::Char('/') => GlobalAction::Filter,
19 + KeyCode::Char(':') => GlobalAction::Command,
20 + _ => GlobalAction::Passthrough,
21 + }
22 + }
@@ -0,0 +1,5 @@
1 + use ratatui::layout::{Constraint, Layout, Rect};
2 +
3 + pub fn split(area: Rect) -> [Rect; 2] {
4 + Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area)
5 + }
@@ -0,0 +1,10 @@
1 + pub mod footer;
2 + pub mod keys;
3 + pub mod layout;
4 + pub mod palette;
5 + pub mod selection;
6 + pub mod style;
7 +
8 + pub use footer::{Footer, Hint, hint};
9 + pub use keys::{GlobalAction, classify};
10 + pub use selection::{MARKER, selected_style};
@@ -0,0 +1,10 @@
1 + use ratatui::style::Color;
2 +
3 + pub const BG: Color = Color::Rgb(0xf7, 0xf3, 0xee);
4 + pub const FG: Color = Color::Rgb(0x60, 0x5a, 0x52);
5 + pub const DIM: Color = Color::Rgb(0x8c, 0x86, 0x7e);
6 + pub const SELECTION_BG: Color = Color::Rgb(0xdd, 0xd2, 0xbc);
7 + pub const ACCENT: Color = Color::Rgb(0x65, 0x87, 0xbf);
8 + pub const STATE_OK: Color = Color::Rgb(0x5d, 0x7a, 0x55);
9 + pub const STATE_WARN: Color = Color::Rgb(0xa7, 0x8a, 0x5e);
10 + pub const STATE_FAULT: Color = Color::Rgb(0xc4, 0x6a, 0x6a);
@@ -0,0 +1,11 @@
1 + use crate::palette;
2 + use ratatui::style::{Modifier, Style};
3 +
4 + pub const MARKER: &str = "▶";
5 +
6 + pub fn selected_style() -> Style {
7 + Style::default()
8 + .fg(palette::FG)
9 + .bg(palette::SELECTION_BG)
10 + .add_modifier(Modifier::BOLD)
11 + }
@@ -0,0 +1,34 @@
1 + use crate::palette;
2 + use ratatui::style::{Modifier, Style};
3 + use ratatui::text::Span;
4 +
5 + pub fn fg(s: impl Into<String>) -> Span<'static> {
6 + Span::styled(s.into(), Style::default().fg(palette::FG))
7 + }
8 +
9 + pub fn dim(s: impl Into<String>) -> Span<'static> {
10 + Span::styled(s.into(), Style::default().fg(palette::DIM))
11 + }
12 +
13 + pub fn bold(s: impl Into<String>) -> Span<'static> {
14 + Span::styled(
15 + s.into(),
16 + Style::default().fg(palette::FG).add_modifier(Modifier::BOLD),
17 + )
18 + }
19 +
20 + pub fn ok(s: impl Into<String>) -> Span<'static> {
21 + Span::styled(s.into(), Style::default().fg(palette::STATE_OK))
22 + }
23 +
24 + pub fn warn(s: impl Into<String>) -> Span<'static> {
25 + Span::styled(s.into(), Style::default().fg(palette::STATE_WARN))
26 + }
27 +
28 + pub fn fault(s: impl Into<String>) -> Span<'static> {
29 + Span::styled(s.into(), Style::default().fg(palette::STATE_FAULT))
30 + }
31 +
32 + pub fn inactive(s: impl Into<String>) -> Span<'static> {
33 + dim(s)
34 + }
@@ -0,0 +1,20 @@
1 + [package]
2 + name = "sysop"
3 + version = "0.1.0"
4 + edition.workspace = true
5 + rust-version.workspace = true
6 + license.workspace = true
7 +
8 + [[bin]]
9 + name = "sysop"
10 + path = "src/main.rs"
11 +
12 + [dependencies]
13 + sysop-tui.workspace = true
14 + ratatui.workspace = true
15 + crossterm.workspace = true
16 + anyhow.workspace = true
17 + clap.workspace = true
18 + serde.workspace = true
19 + serde_json.workspace = true
20 + toml.workspace = true
@@ -0,0 +1,254 @@
1 + # Mountaineer role → tool registry.
2 + #
3 + # Each role is a stable name for a job the OS needs done. The tool filling
4 + # the role is implementation detail and may change between releases; the
5 + # role name does not. `sysop info <role>` reads this file.
6 + #
7 + # Order matters: roles are presented in the list view in the order they
8 + # appear here. Group by concern (core → network → graphical).
9 +
10 + # --- core ---
11 +
12 + [[role]]
13 + name = "init"
14 + description = "Service supervisor and runlevel manager"
15 + tool = "s6 + s6-rc"
16 + packages = ["s6", "s6-rc"]
17 + config_paths = ["/etc/s6/", "/etc/s6-rc/"]
18 +
19 + [[role]]
20 + name = "shell-login"
21 + description = "POSIX /bin/sh"
22 + tool = "busybox ash"
23 + packages = ["busybox"]
24 + config_paths = ["/etc/profile", "/etc/profile.d/"]
25 +
26 + [[role]]
27 + name = "shell-interactive"
28 + description = "Login interactive shell with structured pipelines"
29 + tool = "nushell"
30 + packages = ["nushell"]
31 + config_paths = ["~/.config/nushell/"]
32 +
33 + [[role]]
34 + name = "editor"
35 + description = "Modal editor with LSP and tree-sitter"
36 + tool = "helix"
37 + packages = ["helix"]
38 + config_paths = ["~/.config/helix/"]
39 +
40 + [[role]]
41 + name = "multiplexer"
42 + description = "Terminal multiplexer with discoverable keybindings"
43 + tool = "zellij"
44 + packages = ["zellij"]
45 + config_paths = ["~/.config/zellij/"]
46 +
47 + [[role]]
48 + name = "container-runtime"
49 + description = "Rootless container runtime"
50 + tool = "podman"
51 + packages = ["podman"]
52 + config_paths = ["/etc/containers/", "~/.config/containers/"]
53 +
54 + [[role]]
55 + name = "filesystem"
56 + description = "Root filesystem and pool/dataset management"
57 + tool = "ZFS"
58 + packages = ["zfs", "zfs-lts"]
59 + config_paths = ["/etc/zfs/"]
60 +
61 + [[role]]
62 + name = "ssh"
63 + description = "SSH server, hardened defaults"
64 + tool = "OpenSSH"
65 + packages = ["openssh"]
66 + config_paths = ["/etc/ssh/sshd_config", "/etc/ssh/sshd_config.d/"]
67 +
68 + # --- network ---
69 +
70 + [[role]]
71 + name = "firewall"
72 + description = "Stateful packet filter, default-deny"
73 + tool = "nftables"
74 + packages = ["nftables"]
75 + config_paths = ["/etc/nftables.nft", "/etc/nftables.d/"]
76 +
77 + [[role]]
78 + name = "resolver"
79 + description = "Local validating recursive DNS resolver"
80 + tool = "unbound"
81 + packages = ["unbound"]
82 + config_paths = ["/etc/unbound/unbound.conf", "/etc/unbound/conf.d/"]
83 +
84 + [[role]]
85 + name = "time"
86 + description = "NTP client and time synchronisation"
87 + tool = "chrony"
88 + packages = ["chrony"]
89 + config_paths = ["/etc/chrony/chrony.conf"]
90 +
91 + [[role]]
92 + name = "network-wired"
93 + description = "Wired interface configuration"
94 + tool = "ifupdown-ng"
95 + packages = ["ifupdown-ng"]
96 + config_paths = ["/etc/network/interfaces", "/etc/network/interfaces.d/"]
97 +
98 + [[role]]
99 + name = "network-wireless"
100 + description = "Wireless interface daemon"
101 + tool = "iwd"
102 + packages = ["iwd"]
103 + config_paths = ["/etc/iwd/", "/var/lib/iwd/"]
104 +
105 + [[role]]
106 + name = "mesh"
107 + description = "Overlay mesh VPN with mutual auth"
108 + tool = "Nebula"
109 + packages = ["nebula"]
110 + config_paths = ["/etc/nebula/"]
111 +
112 + [[role]]
113 + name = "reverse-proxy"
114 + description = "HTTPS-terminating reverse proxy with auto-TLS"
115 + tool = "Caddy"
116 + packages = ["caddy"]
117 + config_paths = ["/etc/caddy/Caddyfile", "/etc/caddy/conf.d/"]
118 +
119 + [[role]]
120 + name = "mail-outbound"
121 + description = "Outbound mail relay (notifications, alerts)"
122 + tool = "msmtp"
123 + packages = ["msmtp"]
124 + config_paths = ["/etc/msmtprc"]
125 +
126 + # --- observability / data ---
127 +
128 + [[role]]
129 + name = "logs-router"
130 + description = "Structured log collection and routing"
131 + tool = "Vector"
132 + packages = ["vector"]
133 + config_paths = ["/etc/vector/vector.toml"]
134 +
135 + [[role]]
136 + name = "logs-viewer"
137 + description = "Interactive log browser"
138 + tool = "lnav"
139 + packages = ["lnav"]
140 + config_paths = ["~/.config/lnav/"]
141 +
142 + [[role]]
143 + name = "secrets"
144 + description = "Age-encrypted secrets with sops integration"
145 + tool = "age + sops"
146 + packages = ["age", "sops"]
147 + config_paths = ["~/.config/sops/", ".sops.yaml"]
148 +
149 + [[role]]
150 + name = "backup"
151 + description = "Encrypted, deduplicated backups"
152 + tool = "restic"
153 + packages = ["restic"]
154 + config_paths = ["~/.config/restic/"]
155 +
156 + # --- graphical layer (Sysadmin build only) ---
157 +
158 + [[role]]
159 + name = "compositor"
160 + description = "Tiling Wayland compositor"
161 + tool = "river"
162 + packages = ["river"]
163 + config_paths = ["~/.config/river/init"]
164 +
165 + [[role]]
166 + name = "terminal"
167 + description = "Wayland terminal emulator"
168 + tool = "foot"
169 + packages = ["foot"]
170 + config_paths = ["~/.config/foot/foot.ini"]
171 +
172 + [[role]]
173 + name = "bar"
174 + description = "Status bar (read-only system surface)"
175 + tool = "yambar"
176 + packages = ["yambar"]
177 + config_paths = ["~/.config/yambar/config.yml"]
178 +
179 + [[role]]
180 + name = "launcher"
181 + description = "Application launcher"
182 + tool = "fuzzel"
183 + packages = ["fuzzel"]
184 + config_paths = ["~/.config/fuzzel/fuzzel.ini"]
185 +
186 + [[role]]
187 + name = "clipboard"
188 + description = "Clipboard history"
189 + tool = "cliphist"
190 + packages = ["cliphist"]
191 + config_paths = []
192 +
193 + [[role]]
194 + name = "notifications"
195 + description = "Wayland notification daemon"
196 + tool = "fnott"
197 + packages = ["fnott"]
198 + config_paths = ["~/.config/fnott/fnott.ini"]
199 +
200 + [[role]]
201 + name = "screenshots"
202 + description = "Region capture, annotation, pipeline"
203 + tool = "grim + slurp + swappy"
204 + packages = ["grim", "slurp", "swappy"]
205 + config_paths = ["~/.config/swappy/"]
206 +
207 + [[role]]
208 + name = "lock"
209 + description = "Screen locker"
210 + tool = "swaylock"
211 + packages = ["swaylock"]
212 + config_paths = ["~/.config/swaylock/config"]
213 +
214 + [[role]]
215 + name = "idle"
216 + description = "Idle event dispatcher"
217 + tool = "swayidle"
218 + packages = ["swayidle"]
219 + config_paths = ["~/.config/swayidle/config"]
220 +
221 + [[role]]
222 + name = "wallpaper"
223 + description = "Wallpaper / background"
224 + tool = "swaybg"
225 + packages = ["swaybg"]
226 + config_paths = []
227 +
228 + [[role]]
229 + name = "audio"
230 + description = "Audio server and session manager"
231 + tool = "pipewire + wireplumber"
232 + packages = ["pipewire", "wireplumber"]
233 + config_paths = ["/etc/pipewire/", "~/.config/pipewire/", "~/.config/wireplumber/"]
234 +
235 + [[role]]
236 + name = "seat"
237 + description = "Seat / device management for non-root Wayland"
238 + tool = "seatd"
239 + packages = ["seatd"]
240 + config_paths = []
241 +
242 + [[role]]
243 + name = "brightness"
244 + description = "Backlight control"
245 + tool = "brightnessctl"
246 + packages = ["brightnessctl"]
247 + config_paths = []
248 +
249 + [[role]]
250 + name = "power"
251 + description = "Laptop power management profiles"
252 + tool = "tlp"
253 + packages = ["tlp"]
254 + config_paths = ["/etc/tlp.conf", "/etc/tlp.d/"]
@@ -0,0 +1,47 @@
1 + use anyhow::{Context, Result};
2 + use serde::{Deserialize, Serialize};
3 + use std::fs;
4 + use std::path::PathBuf;
5 +
6 + const DEFAULT_PATH: &str = "/etc/mountaineer/sysop.toml";
7 +
8 + pub fn path() -> PathBuf {
9 + std::env::var_os("SYSOP_CONFIG")
10 + .map(PathBuf::from)
11 + .unwrap_or_else(|| PathBuf::from(DEFAULT_PATH))
12 + }
13 +
14 + #[derive(Debug, Default, Serialize, Deserialize)]
15 + pub struct Config {
16 + #[serde(default)]
17 + pub motd: Motd,
18 + }
19 +
20 + #[derive(Debug, Serialize, Deserialize)]
21 + pub struct Motd {
22 + pub enabled: bool,
23 + }
24 +
25 + impl Default for Motd {
26 + fn default() -> Self {
27 + Self { enabled: true }
28 + }
29 + }
30 +
31 + pub fn load() -> Result<Config> {
32 + let p = path();
33 + if !p.exists() {
34 + return Ok(Config::default());
35 + }
36 + let s = fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?;
37 + toml::from_str(&s).with_context(|| format!("parse {}", p.display()))
38 + }
39 +
40 + pub fn save(cfg: &Config) -> Result<()> {
41 + let p = path();
42 + if let Some(dir) = p.parent() {
43 + fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
44 + }
45 + let s = toml::to_string_pretty(cfg).context("serialize config")?;
46 + fs::write(&p, s).with_context(|| format!("write {}", p.display()))
47 + }
@@ -0,0 +1,186 @@
1 + use crate::roles::{self, Role};
2 + use anyhow::Result;
3 + use crossterm::event::{self, Event, KeyCode};
4 + use ratatui::Frame;
5 + use ratatui::layout::{Constraint, Layout, Rect};
6 + use ratatui::style::Style;
7 + use ratatui::text::{Line, Span};
8 + use ratatui::widgets::Paragraph;
9 + use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
10 +
11 + enum View {
12 + List { selected: usize },
13 + Detail { idx: usize },
14 + }
15 +
16 + pub fn run(initial: Option<String>) -> Result<i32> {
17 + let roles = roles::load()?;
18 +
19 + let mut view = match initial.as_deref() {
20 + None => View::List { selected: 0 },
21 + Some(name) => match roles::find(&roles, name) {
22 + Some(_) => View::Detail {
23 + idx: roles.iter().position(|r| r.name == name).unwrap(),
24 + },
25 + None => {
26 + eprintln!("sysop: unknown role: {name}");
27 + eprintln!(" run `sysop info` for the list");
28 + return Ok(1);
29 + }
30 + },
31 + };
32 +
33 + let mut terminal = ratatui::init();
34 + let result = event_loop(&mut terminal, &roles, &mut view);
35 + ratatui::restore();
36 + result.map(|_| 0)
37 + }
38 +
39 + fn event_loop(
40 + terminal: &mut ratatui::DefaultTerminal,
41 + roles: &[Role],
42 + view: &mut View,
43 + ) -> Result<()> {
44 + loop {
45 + terminal.draw(|frame| draw(frame, roles, view))?;
46 +
47 + let Event::Key(key) = event::read()? else {
48 + continue;
49 + };
50 + let action = classify(key);
51 +
52 + match view {
53 + View::List { selected } => match action {
54 + GlobalAction::Quit => return Ok(()),
55 + GlobalAction::Passthrough => match key.code {
56 + KeyCode::Char('j') | KeyCode::Down if *selected + 1 < roles.len() => {
57 + *selected += 1;
58 + }
59 + KeyCode::Char('k') | KeyCode::Up => {
60 + *selected = selected.saturating_sub(1);
61 + }
62 + KeyCode::Enter => {
63 + *view = View::Detail { idx: *selected };
64 + }
65 + _ => {}
66 + },
67 + _ => {}
68 + },
69 + View::Detail { idx } => match action {
70 + GlobalAction::Quit => return Ok(()),
71 + GlobalAction::Back => {
72 + *view = View::List { selected: *idx };
73 + }
74 + GlobalAction::Passthrough => match key.code {
75 + KeyCode::Char('j') | KeyCode::Down if *idx + 1 < roles.len() => {
76 + *idx += 1;
77 + }
78 + KeyCode::Char('k') | KeyCode::Up => {
79 + *idx = idx.saturating_sub(1);
80 + }
81 + _ => {}
82 + },
83 + _ => {}
84 + },
85 + }
86 + }
87 + }
88 +
89 + fn draw(frame: &mut Frame, roles: &[Role], view: &View) {
90 + let [body, foot] = layout::split(frame.area());
91 + match view {
92 + View::List { selected } => {
93 + frame.render_widget(list_widget(roles, *selected), body);
94 + frame.render_widget(
95 + &Footer::new([
96 + hint("j/k", "move"),
97 + hint("Enter", "details"),
98 + hint("q", "quit"),
99 + ]),
100 + foot,
101 + );
102 + }
103 + View::Detail { idx } => {
104 + draw_detail(frame, &roles[*idx], body);
105 + frame.render_widget(
106 + &Footer::new([
107 + hint("j/k", "next/prev role"),
108 + hint("Esc", "back"),
109 + hint("q", "quit"),
110 + ]),
111 + foot,
112 + );
113 + }
114 + }
115 + }
116 +
117 + fn list_widget(roles: &[Role], selected: usize) -> Paragraph<'_> {
118 + let name_w = roles.iter().map(|r| r.name.len()).max().unwrap_or(0);
119 + let lines: Vec<Line> = roles
120 + .iter()
121 + .enumerate()
122 + .map(|(i, r)| {
123 + let is_sel = i == selected;
124 + let marker = if is_sel { selection::MARKER } else { " " };
125 + let line = Line::from(vec![
126 + Span::raw(format!(" {marker} ")),
127 + Span::raw(format!("{:<name_w$}", r.name)),
128 + Span::raw(" "),
129 + style::dim(r.tool.clone()),
130 + ]);
131 + if is_sel {
132 + line.style(selection::selected_style())
133 + } else {
134 + line
135 + }
136 + })
137 + .collect();
138 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
139 + }
140 +
141 + fn draw_detail(frame: &mut Frame, role: &Role, area: Rect) {
142 + let [_, right] =
143 + Layout::horizontal([Constraint::Length(2), Constraint::Min(0)]).areas(area);
144 + let [_, body] =
145 + Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(right);
146 +
147 + let label_w = 12;
148 + let mut lines: Vec<Line> = Vec::new();
149 +
150 + lines.push(field_line("role", &role.name, label_w));
151 + lines.push(field_line("tool", &role.tool, label_w));
152 + lines.push(field_line(
153 + "packages",
154 + &role.packages.join(", "),
155 + label_w,
156 + ));
157 +
158 + if role.config_paths.is_empty() {
159 + lines.push(field_line("config", "(none)", label_w));
160 + } else {
161 + lines.push(field_line("config", &role.config_paths[0], label_w));
162 + for extra in &role.config_paths[1..] {
163 + lines.push(continuation_line(extra, label_w));
164 + }
165 + }
166 +
167 + lines.push(Line::from(""));
168 + lines.push(field_line("description", &role.description, label_w));
169 +
170 + let para = Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG));
171 + frame.render_widget(para, body);
172 + }
173 +
174 + fn field_line(label: &str, value: &str, label_w: usize) -> Line<'static> {
175 + Line::from(vec![
176 + style::dim(format!("{:<label_w$} ", label)),
177 + style::fg(value.to_string()),
178 + ])
179 + }
180 +
181 + fn continuation_line(value: &str, label_w: usize) -> Line<'static> {
182 + Line::from(vec![
183 + Span::raw(" ".repeat(label_w + 2)),
184 + style::fg(value.to_string()),
185 + ])
186 + }
@@ -0,0 +1,79 @@
1 + mod config;
2 + mod info;
3 + mod motd;
4 + mod net;
5 + mod roles;
6 + mod services;
7 + mod storage;
8 + mod wifi;
9 +
10 + use clap::{Parser, Subcommand};
11 +
12 + #[derive(Parser)]
13 + #[command(name = "sysop", about = "Mountaineer operator interface")]
14 + struct Cli {
15 + #[command(subcommand)]
16 + command: Command,
17 + }
18 +
19 + #[derive(Subcommand)]
20 + enum Command {
21 + /// Show which tool fills a role and where its config lives
22 + Info {
23 + /// Role name (omit for the full list)
24 + role: Option<String>,
25 + },
26 + /// Login tip rotation (on/off/print)
27 + Motd {
28 + #[command(subcommand)]
29 + action: Option<MotdAction>,
30 + },
31 + /// s6-rc service health view
32 + Services {
33 + /// One-line summary for the yambar `svc` segment
34 + #[arg(long)]
35 + summary: bool,
36 + },
37 + /// ZFS pool health view
38 + Storage {
39 + /// One-line summary for the yambar `zfs` segment
40 + #[arg(long)]
41 + summary: bool,
42 + },
43 + /// Connect to a wireless network
44 + Wifi,
45 + /// Network interface status
46 + Net {
47 + /// One-line summary for the yambar `net` segment
48 + #[arg(long)]
49 + summary: bool,
50 + },
51 + }
52 +
53 + #[derive(Subcommand)]
54 + enum MotdAction {
55 + /// Enable the login tip
56 + On,
57 + /// Disable the login tip
58 + Off,
59 + /// Emit the current tip (called by /etc/profile.d/sysop-motd.sh on login)
60 + Print,
61 + }
62 +
63 + fn main() -> anyhow::Result<()> {
64 + let cli = Cli::parse();
65 + let code = match cli.command {
66 + Command::Info { role } => info::run(role)?,
67 + Command::Motd { action } => match action {
68 + None => motd::status()?,
69 + Some(MotdAction::On) => motd::set(true)?,
70 + Some(MotdAction::Off) => motd::set(false)?,
71 + Some(MotdAction::Print) => motd::print()?,
72 + },
73 + Command::Services { summary } => services::run(summary)?,
74 + Command::Storage { summary } => storage::run(summary)?,
75 + Command::Wifi => wifi::run()?,
76 + Command::Net { summary } => net::run(summary)?,
77 + };
78 + std::process::exit(code);
79 + }
@@ -0,0 +1,43 @@
1 + use crate::config;
2 + use anyhow::Result;
3 + use std::time::SystemTime;
4 +
5 + const TIPS: &[&str] = &[
6 + "sysop info <role> — show which tool fills a role and where its config lives",
7 + "sysop services — s6-rc service health",
8 + "sysop wifi — network connections",
9 + "sysop storage — ZFS pool state",
10 + "sysop logs <svc> — structured logs for a service",
11 + "sysop help — list all sysop commands",
12 + ];
13 +
14 + pub fn status() -> Result<i32> {
15 + let cfg = config::load()?;
16 + let state = if cfg.motd.enabled { "on" } else { "off" };
17 + println!("motd: {state}");
18 + println!("config: {}", config::path().display());
19 + Ok(0)
20 + }
21 +
22 + pub fn set(enabled: bool) -> Result<i32> {
23 + let mut cfg = config::load()?;
24 + cfg.motd.enabled = enabled;
25 + config::save(&cfg)?;
26 + println!("motd: {}", if enabled { "on" } else { "off" });
27 + Ok(0)
28 + }
29 +
30 + pub fn print() -> Result<i32> {
31 + let cfg = config::load()?;
32 + if !cfg.motd.enabled {
33 + return Ok(0);
34 + }
35 + let bucket = SystemTime::now()
36 + .duration_since(SystemTime::UNIX_EPOCH)
37 + .map(|d| d.as_secs() / 60)
38 + .unwrap_or(0) as usize;
39 + let idx = bucket % TIPS.len();
40 + println!();
41 + println!(" {}", TIPS[idx]);
42 + Ok(0)
43 + }
@@ -0,0 +1,263 @@
1 + use anyhow::{Context, Result};
2 + use crossterm::event::{self, Event, KeyCode};
3 + use ratatui::style::Style;
4 + use ratatui::text::{Line, Span};
5 + use ratatui::widgets::Paragraph;
6 + use serde::Deserialize;
7 + use std::process::Command;
8 + use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
9 +
10 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 + pub enum Kind {
12 + Wired,
13 + Wireless,
14 + Loopback,
15 + Other,
16 + }
17 +
18 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 + pub enum State {
20 + Up,
21 + Down,
22 + }
23 +
24 + impl State {
25 + fn span(&self) -> Span<'static> {
26 + match self {
27 + State::Up => style::ok("up"),
28 + State::Down => style::fault("down"),
29 + }
30 + }
31 + }
32 +
33 + #[derive(Debug, Clone)]
34 + pub struct Interface {
35 + pub name: String,
36 + pub kind: Kind,
37 + pub state: State,
38 + pub addresses: Vec<String>,
39 + pub ssid: Option<String>,
40 + }
41 +
42 + impl Interface {
43 + fn is_routable(&self) -> bool {
44 + matches!(self.kind, Kind::Wired | Kind::Wireless) && self.state == State::Up
45 + }
46 + }
47 +
48 + pub trait Backend {
49 + fn list(&self) -> Result<Vec<Interface>>;
50 + }
51 +
52 + fn detect() -> Box<dyn Backend> {
53 + if Iproute2.probe().is_ok() {
54 + Box::new(Iproute2)
55 + } else {
56 + Box::new(Mock)
57 + }
58 + }
59 +
60 + struct Iproute2;
61 +
62 + impl Iproute2 {
63 + fn probe(&self) -> Result<()> {
64 + let out = Command::new("ip")
65 + .arg("-V")
66 + .output()
67 + .context("invoke ip")?;
68 + if out.status.success() {
69 + Ok(())
70 + } else {
71 + anyhow::bail!("ip unresponsive")
72 + }
73 + }
74 + }
75 +
76 + #[derive(Deserialize)]
77 + struct IpLink {
78 + ifname: String,
79 + #[serde(default)]
80 + flags: Vec<String>,
81 + #[serde(default)]
82 + addr_info: Vec<IpAddrEntry>,
83 + }
84 +
85 + #[derive(Deserialize)]
86 + struct IpAddrEntry {
87 + local: String,
88 + prefixlen: u8,
89 + }
90 +
91 + impl Backend for Iproute2 {
92 + fn list(&self) -> Result<Vec<Interface>> {
93 + let out = Command::new("ip")
94 + .args(["-json", "addr", "show"])
95 + .output()
96 + .context("ip addr show")?;
97 + if !out.status.success() {
98 + anyhow::bail!("ip addr show failed");
99 + }
100 + let links: Vec<IpLink> =
101 + serde_json::from_slice(&out.stdout).context("parse ip -json output")?;
102 + Ok(links
103 + .into_iter()
104 + .map(|link| {
105 + let kind = classify_iface(&link.ifname, &link.flags);
106 + let state = if link.flags.iter().any(|f| f == "UP") {
107 + State::Up
108 + } else {
109 + State::Down
110 + };
111 + let addresses = link
112 + .addr_info
113 + .into_iter()
114 + .map(|a| format!("{}/{}", a.local, a.prefixlen))
115 + .collect();
116 + Interface {
117 + name: link.ifname,
118 + kind,
119 + state,
120 + addresses,
121 + ssid: None,
122 + }
123 + })
124 + .collect())
125 + }
126 + }
127 +
128 + fn classify_iface(name: &str, flags: &[String]) -> Kind {
129 + if flags.iter().any(|f| f == "LOOPBACK") {
130 + Kind::Loopback
131 + } else if name.starts_with("wl") {
132 + Kind::Wireless
133 + } else if name.starts_with("en") || name.starts_with("eth") {
134 + Kind::Wired
135 + } else {
136 + Kind::Other
137 + }
138 + }
139 +
140 + struct Mock;
141 +
142 + impl Backend for Mock {
143 + fn list(&self) -> Result<Vec<Interface>> {
144 + Ok(vec![
145 + Interface {
146 + name: "lo".into(),
147 + kind: Kind::Loopback,
148 + state: State::Up,
149 + addresses: vec!["127.0.0.1/8".into(), "::1/128".into()],
150 + ssid: None,
151 + },
152 + Interface {
153 + name: "eth0".into(),
154 + kind: Kind::Wired,
155 + state: State::Up,
156 + addresses: vec!["192.168.1.50/24".into(), "fe80::a00:27ff:fe4e:66a1/64".into()],
157 + ssid: None,
158 + },
159 + Interface {
160 + name: "wlan0".into(),
161 + kind: Kind::Wireless,
162 + state: State::Up,
163 + addresses: vec!["192.168.1.51/24".into()],
164 + ssid: Some("home-net".into()),
165 + },
166 + ])
167 + }
168 + }
169 +
170 + pub fn run(summary: bool) -> Result<i32> {
171 + let interfaces = detect().list()?;
172 +
173 + if summary {
174 + println!("{}", summary_line(&interfaces));
175 + return Ok(0);
176 + }
177 +
178 + let mut terminal = ratatui::init();
179 + let result = event_loop(&mut terminal, &interfaces);
180 + ratatui::restore();
181 + result.map(|_| 0)
182 + }
183 +
184 + fn summary_line(interfaces: &[Interface]) -> String {
185 + let wired = interfaces.iter().find(|i| i.kind == Kind::Wired && i.is_routable());
186 + let wireless = interfaces.iter().find(|i| i.kind == Kind::Wireless && i.is_routable());
187 + match (wired, wireless) {
188 + (Some(w), _) => format!("{} ↑", w.name),
189 + (None, Some(w)) => match &w.ssid {
190 + Some(ssid) => format!("{} {}", w.name, ssid),
191 + None => format!("{} ↑", w.name),
192 + },
193 + (None, None) => "net ↓".into(),
194 + }
195 + }
196 +
197 + fn event_loop(
198 + terminal: &mut ratatui::DefaultTerminal,
199 + interfaces: &[Interface],
200 + ) -> Result<()> {
201 + let mut selected: usize = 0;
202 + let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]);
203 + loop {
204 + terminal.draw(|frame| {
205 + let [body, foot] = layout::split(frame.area());
206 + frame.render_widget(list_widget(interfaces, selected), body);
207 + frame.render_widget(&footer, foot);
208 + })?;
209 +
210 + let Event::Key(key) = event::read()? else {
211 + continue;
212 + };
213 + match classify(key) {
214 + GlobalAction::Quit => return Ok(()),
215 + GlobalAction::Passthrough => match key.code {
216 + KeyCode::Char('j') | KeyCode::Down if selected + 1 < interfaces.len() => {
217 + selected += 1;
218 + }
219 + KeyCode::Char('k') | KeyCode::Up => {
220 + selected = selected.saturating_sub(1);
221 + }
222 + _ => {}
223 + },
224 + _ => {}
225 + }
226 + }
227 + }
228 +
229 + fn list_widget(interfaces: &[Interface], selected: usize) -> Paragraph<'_> {
230 + let name_w = interfaces.iter().map(|i| i.name.len()).max().unwrap_or(0).max(4);
231 + let lines: Vec<Line> = interfaces
232 + .iter()
233 + .enumerate()
234 + .map(|(i, iface)| {
235 + let is_sel = i == selected;
236 + let marker = if is_sel { selection::MARKER } else { " " };
237 + let addrs = if iface.addresses.is_empty() {
238 + String::new()
239 + } else {
240 + iface.addresses.join(", ")
241 + };
242 + let ssid_seg = match &iface.ssid {
243 + Some(s) => format!(" {s}"),
244 + None => String::new(),
245 + };
246 + let line = Line::from(vec![
247 + Span::raw(format!(" {marker} ")),
248 + Span::raw(format!("{:<name_w$}", iface.name)),
249 + Span::raw(" "),
250 + iface.state.span(),
251 + style::fg(ssid_seg),
252 + Span::raw(" "),
253 + style::dim(addrs),
254 + ]);
255 + if is_sel {
256 + line.style(selection::selected_style())
257 + } else {
258 + line
259 + }
260 + })
261 + .collect();
262 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
263 + }
@@ -0,0 +1,27 @@
1 + use anyhow::{Context, Result};
2 + use serde::Deserialize;
3 +
4 + const EMBEDDED: &str = include_str!("../data/roles.toml");
5 +
6 + #[derive(Debug, Clone, Deserialize)]
7 + pub struct Role {
8 + pub name: String,
9 + pub description: String,
10 + pub tool: String,
11 + pub packages: Vec<String>,
12 + pub config_paths: Vec<String>,
13 + }
14 +
15 + #[derive(Debug, Deserialize)]
16 + struct Registry {
17 + role: Vec<Role>,
18 + }
19 +
20 + pub fn load() -> Result<Vec<Role>> {
21 + let reg: Registry = toml::from_str(EMBEDDED).context("parse embedded roles.toml")?;
22 + Ok(reg.role)
23 + }
24 +
25 + pub fn find<'a>(roles: &'a [Role], name: &str) -> Option<&'a Role> {
26 + roles.iter().find(|r| r.name == name)
27 + }