diff --git a/.cursor/rules/kcore-console-tui.mdc b/.cursor/rules/kcore-console-tui.mdc new file mode 100644 index 0000000..aff0154 --- /dev/null +++ b/.cursor/rules/kcore-console-tui.mdc @@ -0,0 +1,34 @@ +--- +description: Standards for the Rust Ratatui appliance console +globs: crates/kcore-console/**/*.rs,modules/kcore-branding.nix,packaging/systemd/kcore-console.service,docs/appliance-console.md,docs/user/appliance-console.md +alwaysApply: false +--- + +# kcore Console TUI + +The local appliance console is a Rust TUI in `crates/kcore-console` using +Ratatui with the crossterm backend. Keep it product-grade and read-only: + +- Production mode must never expose a shell. `q`, `Esc`, and `Ctrl+C` must not + exit in default mode; only `--dev` may exit interactively. +- Inventory collection must not block rendering. Use a worker/thread/channel or + another non-blocking path for NIC, disk, API, and diagnostics refresh. +- Treat missing host data as normal. Display `—` instead of panicking or leaving + empty cells for unavailable sysfs, `ip`, `lsblk`, API, or systemd data. +- Keep UI code separate from collection code: `ui/` renders `AppState`; + `inventory/` reads Linux/API state; `app.rs` owns navigation and selection. +- Use Ratatui widgets (`Block`, `Paragraph`, `Table`, `Tabs`, `List`, `Gauge` + when useful) and keep the visual language dark, clean, and appliance-like. +- If changing `modules/kcore-branding.nix`, verify gettys and `autovt@` remain + disabled so `tty2`-`tty6` cannot show login prompts. +- Add or update focused tests for parsing, formatting, navigation, and + production-vs-dev exit policy whenever behavior changes. + +Before shipping a console change, run at least: + +```bash +nix develop -c cargo fmt +nix develop -c cargo test -p kcore-console +nix develop -c cargo clippy -p kcore-console --all-targets -- --deny warnings +nix build .#kcore-console +``` diff --git a/Cargo.lock b/Cargo.lock index c4d8640..a04e273 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -115,7 +130,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -127,7 +142,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -166,7 +181,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -177,7 +192,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -197,7 +221,7 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -213,7 +237,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn", + "syn 2.0.117", ] [[package]] @@ -360,21 +384,42 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -390,12 +435,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.1" @@ -408,6 +468,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.57" @@ -426,6 +495,25 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -457,7 +545,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -498,6 +586,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -561,6 +663,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.11.0" @@ -579,6 +690,12 @@ dependencies = [ "convert_case 0.11.0", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -594,6 +711,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -604,12 +748,73 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der-parser" version = "10.0.0" @@ -641,7 +846,29 @@ checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", ] [[package]] @@ -675,6 +902,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -683,7 +922,16 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] @@ -745,6 +993,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -778,18 +1035,51 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -808,6 +1098,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -879,7 +1175,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1037,7 +1333,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1045,6 +1341,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1225,6 +1526,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1312,6 +1637,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1355,6 +1686,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -1411,6 +1764,32 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "kcore-console" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "crossterm", + "ctrlc", + "libc", + "ratatui", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "kcore-controller" version = "0.1.0" @@ -1468,6 +1847,14 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "kcore-disk-layout-yaml" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + [[package]] name = "kcore-disko-types" version = "0.1.0" @@ -1484,6 +1871,7 @@ dependencies = [ "base64", "clap", "dirs", + "kcore-disk-layout-yaml", "kcore-sanitize", "proptest", "prost", @@ -1540,6 +1928,12 @@ dependencies = [ name = "kcore-sanitize" version = "0.1.0" +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1659,7 +2053,7 @@ dependencies = [ "quote", "rstml", "serde", - "syn", + "syn 2.0.117", "walkdir", ] @@ -1698,7 +2092,7 @@ dependencies = [ "rstml", "rustc_version", "server_fn_macro", - "syn", + "syn 2.0.117", "uuid", ] @@ -1751,7 +2145,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1801,7 +2195,16 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" @@ -1812,6 +2215,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1827,6 +2236,25 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "manyhow" version = "0.11.4" @@ -1836,7 +2264,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1877,6 +2305,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1906,6 +2349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1939,6 +2383,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1974,6 +2443,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1992,6 +2472,30 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "oco_ref" version = "0.2.1" @@ -2035,6 +2539,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "parking" version = "2.2.1" @@ -2092,16 +2605,111 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "petgraph" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "indexmap 2.13.0", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -2119,7 +2727,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2140,6 +2748,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -2171,7 +2785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2193,7 +2807,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2224,7 +2838,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "version_check", "yansi", ] @@ -2235,9 +2849,9 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", - "bitflags", + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.11.0", "num-traits", "rand 0.9.2", "rand_chacha 0.9.0", @@ -2274,7 +2888,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn", + "syn 2.0.117", "tempfile", ] @@ -2288,7 +2902,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2334,7 +2948,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2417,6 +3031,91 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rcgen" version = "0.14.7" @@ -2482,7 +3181,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2491,7 +3190,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -2558,7 +3257,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn", + "syn 2.0.117", "syn_derive", "thiserror 2.0.18", ] @@ -2569,7 +3268,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2607,7 +3306,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -2650,9 +3349,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -2741,7 +3440,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2864,7 +3563,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "xxhash-rust", ] @@ -2875,7 +3574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" dependencies = [ "server_fn_macro", - "syn", + "syn 2.0.117", ] [[package]] @@ -2915,6 +3614,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2925,6 +3645,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -2978,18 +3704,56 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3010,7 +3774,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3027,7 +3791,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3075,6 +3839,69 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3101,7 +3928,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3112,7 +3939,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3141,7 +3968,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -3199,7 +4028,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3322,7 +4151,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3380,7 +4209,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -3432,7 +4261,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3514,7 +4343,7 @@ checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3523,6 +4352,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unarray" version = "0.1.4" @@ -3547,6 +4382,23 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3613,6 +4465,7 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "wasm-bindgen", @@ -3636,6 +4489,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -3734,7 +4596,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3801,7 +4663,7 @@ dependencies = [ "base16", "quote", "sha2", - "syn", + "syn 2.0.117", ] [[package]] @@ -3810,7 +4672,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap 2.13.0", "semver", @@ -3826,6 +4688,94 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3835,12 +4785,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3962,7 +4971,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3978,7 +4987,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3990,7 +4999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap 2.13.0", "log", "serde", @@ -4085,7 +5094,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4106,7 +5115,7 @@ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4126,7 +5135,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4166,7 +5175,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ca1f8db..e34dfd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ members = [ "crates/kcore-sanitize", "crates/kcore-disko-types", + "crates/kcore-disk-layout-yaml", + "crates/kcore-console", "crates/node-agent", "crates/controller", "crates/kctl", diff --git a/crates/kcore-console/Cargo.toml b/crates/kcore-console/Cargo.toml new file mode 100644 index 0000000..1d962fc --- /dev/null +++ b/crates/kcore-console/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "kcore-console" +version = "0.1.0" +edition = "2021" +description = "kcore hypervisor appliance TUI (Ratatui / crossterm)" +build = "build.rs" + +[dependencies] +chrono = { version = "0.4" } +clap = { version = "4", features = ["derive"] } +crossterm = "0.29" +ctrlc = "3" +ratatui = "0.30" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[lints] +workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/kcore-console/build.rs b/crates/kcore-console/build.rs new file mode 100644 index 0000000..325a3a6 --- /dev/null +++ b/crates/kcore-console/build.rs @@ -0,0 +1,20 @@ +//! Embeds an optional VCS revision when `KCORE_GIT_REV` is set in the build environment. +fn main() { + if let Ok(rev) = std::env::var("KCORE_GIT_REV") { + if !rev.trim().is_empty() { + println!("cargo:rustc-env=KCORE_GIT_REV={rev}"); + return; + } + } + if let Some(r) = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + println!("cargo:rustc-env=KCORE_GIT_REV={r}"); + } +} diff --git a/crates/kcore-console/src/app.rs b/crates/kcore-console/src/app.rs new file mode 100644 index 0000000..71f60a2 --- /dev/null +++ b/crates/kcore-console/src/app.rs @@ -0,0 +1,126 @@ +//! App state, page enum, and keyboard / exit policy (dev vs production). + +use crate::inventory::Snapshot; + +/// Top-level TUI pages (tabs). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Page { + #[default] + Overview, + Network, + Storage, + Diagnostics, + Help, +} + +impl Page { + pub const ALL: [Page; 5] = [ + Page::Overview, + Page::Network, + Page::Storage, + Page::Diagnostics, + Page::Help, + ]; + + pub fn label(self) -> &'static str { + match self { + Page::Overview => "Overview", + Page::Network => "Network", + Page::Storage => "Storage", + Page::Diagnostics => "Diagnostics", + Page::Help => "Help", + } + } + + pub fn next(self) -> Page { + match self { + Page::Overview => Page::Network, + Page::Network => Page::Storage, + Page::Storage => Page::Diagnostics, + Page::Diagnostics => Page::Help, + Page::Help => Page::Overview, + } + } + + pub fn prev(self) -> Page { + match self { + Page::Overview => Page::Help, + Page::Network => Page::Overview, + Page::Storage => Page::Network, + Page::Diagnostics => Page::Storage, + Page::Help => Page::Diagnostics, + } + } +} + +/// Row selection for scrollable tables (per page). +#[derive(Debug, Clone, Default)] +pub struct AppState { + pub page: Page, + pub dev: bool, + /// Selection index for Network and Storage table rows. + pub network_sel: usize, + pub storage_sel: usize, + pub last_msg: Option, + pub snapshot: Snapshot, +} + +impl AppState { + pub fn new(dev: bool, initial: Snapshot) -> Self { + Self { + page: Page::default(), + dev, + network_sel: 0, + storage_sel: 0, + last_msg: None, + snapshot: initial, + } + } + + /// When `true`, the `q` key exits the TUI. Only enabled in dev mode. + pub fn allow_exit_on_q(&self) -> bool { + self.dev + } + + pub fn clamp_network_selection(&mut self) { + let n = self.snapshot.nics.len(); + if n == 0 { + self.network_sel = 0; + } else { + self.network_sel = self.network_sel.min(n - 1); + } + } + + pub fn clamp_storage_selection(&mut self) { + let n = self.snapshot.disks.len(); + if n == 0 { + self.storage_sel = 0; + } else { + self.storage_sel = self.storage_sel.min(n - 1); + } + } +} + +#[cfg(test)] +mod tests { + use super::{AppState, Page, Snapshot}; + + #[test] + fn page_tabs_wrap() { + assert_eq!(Page::Overview.next(), Page::Network); + assert_eq!(Page::Help.next(), Page::Overview); + assert_eq!(Page::Overview.prev(), Page::Help); + } + + #[test] + fn production_mode_disallows_q_exit() { + let s = AppState::new(false, Snapshot::default()); + assert!(!s.allow_exit_on_q()); + } + + #[test] + fn dev_mode_allows_q_exit() { + let s = AppState::new(true, Snapshot::default()); + assert!(s.allow_exit_on_q()); + } +} diff --git a/crates/kcore-console/src/inventory/api.rs b/crates/kcore-console/src/inventory/api.rs new file mode 100644 index 0000000..a0887e2 --- /dev/null +++ b/crates/kcore-console/src/inventory/api.rs @@ -0,0 +1,46 @@ +//! Local kcore API reachability (TCP probe to the node-agent port). + +use std::net::TcpStream; +use std::time::Duration; + +/// Default node-agent gRPC port (kcore). +pub const KCORE_API_PORT: u16 = 9091; +const DIAL: Duration = Duration::from_millis(400); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApiStatus { + Unavailable, + /// Port accepted a connection; `healthy` if TCP probe is enough for now. + Reachable { + healthy: bool, + }, +} + +/// TCP connect to 127.0.0.1:9091 (or `KCORE_API_PORT`). +/// If nothing listens, [ApiStatus::Unavailable]. +pub fn probe() -> ApiStatus { + let port: u16 = std::env::var("KCORE_API_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(KCORE_API_PORT); + if matches_peer(port) { + ApiStatus::Reachable { healthy: true } + } else { + ApiStatus::Unavailable + } +} + +fn matches_peer(port: u16) -> bool { + let addr: std::net::SocketAddr = (std::net::Ipv4Addr::LOCALHOST, port).into(); + TcpStream::connect_timeout(&addr, DIAL).is_ok() +} + +impl std::fmt::Display for ApiStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiStatus::Unavailable => write!(f, "unavailable"), + ApiStatus::Reachable { healthy: true } => write!(f, "available"), + ApiStatus::Reachable { healthy: false } => write!(f, "degraded"), + } + } +} diff --git a/crates/kcore-console/src/inventory/diagnostics.rs b/crates/kcore-console/src/inventory/diagnostics.rs new file mode 100644 index 0000000..edc89f7 --- /dev/null +++ b/crates/kcore-console/src/inventory/diagnostics.rs @@ -0,0 +1,61 @@ +//! systemd / service stub status for the Diagnostics page. + +use std::process::Command; + +#[derive(Debug, Clone, Default)] +pub struct ServiceLine { + pub name: String, + pub status: String, +} + +/// Best-effort `systemctl is-active` for kcore units. +pub fn kcore_diagnostics() -> Vec { + const UNITS: &[(&str, &str)] = &[ + ("kcore-node-agent", "kcore-node-agent.service"), + ("kcore-controller", "kcore-controller.service"), + ("kcore-dashboard", "kcore-dashboard.service"), + ]; + let mut v = Vec::new(); + let has_systemctl = which_systemctl(); + for (label, unit) in UNITS { + let status = if has_systemctl { + if let Some(out) = run_active(unit) { + if out == "active" { + "healthy".to_string() + } else { + format!("{out} (not installed or inactive)") + } + } else { + "—".to_string() + } + } else { + "— (no systemctl)".to_string() + }; + v.push(ServiceLine { + name: (*label).to_string(), + status, + }); + } + v +} + +fn which_systemctl() -> bool { + Command::new("systemctl") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn run_active(unit: &str) -> Option { + let o = Command::new("systemctl") + .args(["is-active", unit]) + .output() + .ok()?; + let s = String::from_utf8_lossy(&o.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} diff --git a/crates/kcore-console/src/inventory/disk.rs b/crates/kcore-console/src/inventory/disk.rs new file mode 100644 index 0000000..320ddc1 --- /dev/null +++ b/crates/kcore-console/src/inventory/disk.rs @@ -0,0 +1,209 @@ +//! Block device inventory (Linux, `lsblk` JSON) with heuristic usage role. + +use serde_json::Value; + +use super::format::format_bytes; + +#[derive(Debug, Clone, Default)] +pub struct Disk { + pub name: String, + pub path: String, + pub model: String, + pub serial: String, + pub size: u64, + pub size_text: String, + pub kind: String, + pub ro: String, + pub mountpoints: String, + pub health: String, + pub usage_role: String, +} + +fn kind_from(rot: Option, model: &str) -> String { + let t = model.to_lowercase(); + if t.contains("nvme") { + return "NVMe".to_string(); + } + if rot == Some(true) { + "HDD".to_string() + } else { + "SSD".to_string() + } +} + +fn walk_mounts(v: &Value, acc: &mut Vec) { + if let Some(pts) = v.get("mountpoints").and_then(|x| x.as_array()) { + for p in pts { + if let Some(s) = p.as_str() { + if !s.is_empty() { + acc.push(s.to_string()); + } + } + } + } + if let Some(s) = v.get("mountpoint").and_then(|x| x.as_str()) { + if !s.is_empty() { + acc.push(s.to_string()); + } + } + if let Some(ch) = v.get("children").and_then(|x| x.as_array()) { + for c in ch { + walk_mounts(c, acc); + } + } +} + +fn usage_role(mounts: &str) -> String { + if mounts == "—" || mounts.is_empty() { + return "unknown".to_string(); + } + if mounts == "/" || mounts.split(',').any(|m| m.trim() == "/") { + return "system".to_string(); + } + if mounts.contains("/var/lib") || mounts.to_lowercase().contains("kcore") { + return "VM storage (hint)".to_string(); + } + "unknown".to_string() +} + +/// Parse `lsblk -J -b` and list top-level `disk` devices. +pub fn list_disks() -> Vec { + let out = match std::process::Command::new("lsblk") + .args([ + "-J", + "-b", + "-o", + "NAME,PATH,TYPE,SIZE,ROTA,RO,MODEL,SERIAL,WWN,MOUNTPOINTS,MOUNTPOINT,FSTYPE,STATE", + ]) + .output() + { + Ok(o) if o.status.success() => o, + _ => return Vec::new(), + }; + parse_disks_from_lsblk_json(&out.stdout) +} + +pub(crate) fn parse_disks_from_lsblk_json(json: &[u8]) -> Vec { + let v: Value = match serde_json::from_slice(json) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let Some(top) = v.get("blockdevices").and_then(|x| x.as_array()) else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for d in top { + if d.get("type").and_then(|t| t.as_str()) != Some("disk") { + continue; + } + let name = d + .get("name") + .and_then(|x| x.as_str()) + .unwrap_or("unknown") + .to_string(); + let path: String = d + .get("path") + .and_then(|x| x.as_str()) + .map(String::from) + .unwrap_or_else(|| format!("/dev/{name}")); + let size = d.get("size").and_then(|x| x.as_u64()).unwrap_or(0); + let ro: String = d + .get("ro") + .and_then(|x| x.as_bool()) + .map(|b| (if b { "ro" } else { "rw" }).to_string()) + .unwrap_or_else(|| "—".to_string()); + let rota = d.get("rota").and_then(|x| x.as_bool()); + let model = d + .get("model") + .and_then(|x| x.as_str()) + .unwrap_or("—") + .trim() + .to_string(); + let serial = d + .get("serial") + .or_else(|| d.get("wwn")) + .and_then(|x| x.as_str()) + .unwrap_or("—") + .trim() + .to_string(); + let mut mps: Vec = Vec::new(); + walk_mounts(d, &mut mps); + mps.sort(); + mps.dedup(); + let mountstr = if mps.is_empty() { + "—".to_string() + } else { + mps.join(", ") + }; + let health = d + .get("health") + .or_else(|| d.get("state")) + .and_then(|x| x.as_str()) + .unwrap_or("—") + .to_string(); + let size_text = if size == 0 { + "—".to_string() + } else { + format_bytes(size) + }; + let model_s = if model.is_empty() { + "—".to_string() + } else { + model + }; + let serial_s = if serial.is_empty() { + "—".to_string() + } else { + serial + }; + rows.push(Disk { + name: name.clone(), + path, + model: model_s, + serial: serial_s, + size, + size_text, + kind: kind_from(rota, d.get("model").and_then(|m| m.as_str()).unwrap_or("")), + ro, + mountpoints: mountstr.clone(), + health, + usage_role: usage_role(&mountstr), + }); + } + rows +} + +#[cfg(test)] +mod tests { + use super::parse_disks_from_lsblk_json; + + #[test] + fn parses_top_level_disks_and_mount_roles() { + let fixture = br#"{ + "blockdevices": [ + { + "name": "nvme0n1", "path": "/dev/nvme0n1", "type": "disk", + "size": 2000398934016, "rota": false, "ro": false, + "model": "Fast NVMe", "serial": "ABC123", + "mountpoints": [null], + "children": [ + {"name": "nvme0n1p1", "type": "part", "mountpoints": ["/"]} + ] + }, + { + "name": "sda", "path": "/dev/sda", "type": "disk", + "size": 1000000000000, "rota": true, "ro": false, + "model": "Archive HDD", "serial": null, + "mountpoints": [null] + } + ] + }"#; + + let disks = parse_disks_from_lsblk_json(fixture); + assert_eq!(disks.len(), 2); + assert_eq!(disks[0].kind, "NVMe"); + assert_eq!(disks[0].usage_role, "system"); + assert_eq!(disks[1].kind, "HDD"); + assert_eq!(disks[1].serial, "—"); + } +} diff --git a/crates/kcore-console/src/inventory/format.rs b/crates/kcore-console/src/inventory/format.rs new file mode 100644 index 0000000..5427d8e --- /dev/null +++ b/crates/kcore-console/src/inventory/format.rs @@ -0,0 +1,58 @@ +//! Human-readable size formatting (binary IEC units). + +const KIB: u128 = 1024; +const MIB: u128 = 1024 * KIB; +const GIB: u128 = 1024 * MIB; +const TIB: u128 = 1024 * GIB; +const PIB: u128 = 1024 * TIB; + +/// Formats a byte count like `931.5 GiB` or `1.80 TiB` (1 decimal, adaptive unit). +pub fn format_bytes(n: u64) -> String { + format_u128(n as u128) +} + +fn format_u128(n: u128) -> String { + if n < KIB { + return format!("{n} B"); + } + let f = n as f64; + if n < MIB { + return format!("{:.1} KiB", f / KIB as f64); + } + if n < GIB { + return format!("{:.1} MiB", f / MIB as f64); + } + if n < TIB { + return format!("{:.1} GiB", f / GIB as f64); + } + if n < PIB { + return format!("{:.2} TiB", f / TIB as f64); + } + format!("{:.2} PiB", f / PIB as f64) +} + +#[cfg(test)] +mod tests { + use super::format_bytes; + + #[test] + fn small_bytes() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(1023), "1023 B"); + } + + #[test] + fn kib() { + assert_eq!(format_bytes(1024), "1.0 KiB"); + let u = 1536; + assert_eq!(format_bytes(u), "1.5 KiB"); + } + + #[test] + fn gib() { + // ~931.5 GiB for a 1 TB disk + let n = 1000_u64 * 1000 * 1000 * 1000; + let s = format_bytes(n); + assert!(s.contains("GiB"), "{s}"); + } +} diff --git a/crates/kcore-console/src/inventory/mod.rs b/crates/kcore-console/src/inventory/mod.rs new file mode 100644 index 0000000..80703de --- /dev/null +++ b/crates/kcore-console/src/inventory/mod.rs @@ -0,0 +1,35 @@ +//! Host inventory: network, disks, metadata, API probe, diagnostics. + +pub mod api; +pub mod diagnostics; +pub mod disk; +pub mod format; +pub mod network; +pub mod route; +pub mod system; + +use self::system::Meta; + +/// Full snapshot for one UI refresh. +#[derive(Debug, Clone, Default)] +pub struct Snapshot { + pub meta: Meta, + pub nics: Vec, + pub disks: Vec, + pub diag: Vec, +} + +/// Collects inventory (may be partially empty on errors). +pub fn load_snapshot() -> Snapshot { + let api = api::probe(); + let nics = network::list_nics(); + let disks = disk::list_disks(); + let diag = diagnostics::kcore_diagnostics(); + let meta = system::load_meta(&api); + Snapshot { + meta, + nics, + disks, + diag, + } +} diff --git a/crates/kcore-console/src/inventory/network.rs b/crates/kcore-console/src/inventory/network.rs new file mode 100644 index 0000000..e289f9c --- /dev/null +++ b/crates/kcore-console/src/inventory/network.rs @@ -0,0 +1,255 @@ +//! Network interface inventory (Linux, `ip -j` with sysfs fallbacks). + +use serde::Deserialize; + +use super::route::read_default_ifname; + +fn dash(s: &str) -> String { + if s.is_empty() { + "—".to_string() + } else { + s.to_string() + } +} + +#[derive(Debug, Clone, Default)] +pub struct Nic { + pub name: String, + pub mac: String, + pub oper_state: String, + pub ipv4: String, + pub ipv6: String, + pub mtu: String, + pub speed: String, + pub driver: String, + pub default_route: bool, + pub management: bool, +} + +#[derive(Debug, Deserialize)] +struct IpEntry { + ifname: String, + addr_info: Option>, +} + +#[derive(Debug, Deserialize)] +struct AddrInfo { + family: String, + local: String, + scope: Option, +} + +pub fn list_nics() -> Vec { + let out = std::process::Command::new("ip") + .args(["-j", "link", "show"]) + .output(); + let links: Vec = out + .ok() + .filter(|o| o.status.success()) + .and_then(|o| serde_json::from_slice(&o.stdout).ok()) + .unwrap_or_default(); + + let out_a = std::process::Command::new("ip") + .args(["-j", "address", "show"]) + .output(); + let addr_json: Option> = out_a + .ok() + .filter(|o| o.status.success()) + .and_then(|o| serde_json::from_slice(&o.stdout).ok()); + + let default_if = read_default_ifname(); + let mgmt_if = default_if.clone().or_else(|| { + std::env::var("KCORE_MGMT_IFACE") + .ok() + .or_else(|| std::env::var("KCORE_MANAGEMENT_IF").ok()) + }); + + let mut rows = build_nics_from_links( + &links, + addr_json.as_deref(), + default_if.as_deref(), + mgmt_if.as_deref(), + ); + if rows.is_empty() { + rows.extend(sysfs_enum_fallback()); + } + rows +} + +fn build_nics_from_links( + links: &[serde_json::Value], + addr_json: Option<&[IpEntry]>, + default_if: Option<&str>, + mgmt_if: Option<&str>, +) -> Vec { + let mut rows = Vec::new(); + for link in links { + let ifname = link + .get("ifname") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if ifname.is_empty() || ifname == "lo" { + continue; + } + let mtu = link + .get("mtu") + .and_then(|v| v.as_u64()) + .map(|m| m.to_string()) + .unwrap_or_else(|| "—".to_string()); + let state = link + .get("operstate") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let mac: String = link + .get("address") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| read_sysfs_mac(&ifname)) + .unwrap_or_else(|| "—".to_string()); + let (v4, v6) = extract_addrs(&ifname, addr_json); + let speed = read_link_speed(&ifname); + let driver = read_driver(&ifname); + let default_route = default_if.map(|d| d == ifname).unwrap_or(false); + let management = mgmt_if + .map(|m| m == ifname) + .unwrap_or_else(|| default_route); + + rows.push(Nic { + name: ifname, + mac: if mac.is_empty() { + "—".to_string() + } else { + mac + }, + oper_state: dash(state), + ipv4: v4, + ipv6: v6, + mtu: dash(&mtu), + speed: dash(&speed), + driver: dash(&driver), + default_route, + management, + }); + } + rows +} + +fn extract_addrs(ifname: &str, entries: Option<&[IpEntry]>) -> (String, String) { + let mut v4 = "—".to_string(); + let mut v6 = "—".to_string(); + let Some(entries) = entries else { + return (v4, v6); + }; + for e in entries { + if e.ifname != ifname { + continue; + } + for a in e.addr_info.as_deref().unwrap_or(&[]) { + if a.family == "inet" { + if a.scope.as_deref() == Some("global") || v4 == "—" { + v4 = a.local.clone(); + } + } else if a.family == "inet6" { + if a.local == "::1" { + continue; + } + if a.scope.as_deref() == Some("global") || v6 == "—" { + v6 = a.local.clone(); + } + } + } + } + (v4, v6) +} + +fn read_link_speed(ifname: &str) -> String { + let p = format!("/sys/class/net/{ifname}/speed"); + match std::fs::read_to_string(&p) { + Ok(s) => { + let t = s.trim(); + if t == "-1" { + "—".to_string() + } else { + format!("{t} Mbps") + } + } + Err(_) => "—".to_string(), + } +} + +fn read_driver(ifname: &str) -> String { + // /sys/class/net/DEVICE/uevent has DRIVER= or we follow device/ driver link + let p = format!("/sys/class/net/{ifname}/device/uevent"); + if let Ok(s) = std::fs::read_to_string(p) { + for l in s.lines() { + if let Some(d) = l.strip_prefix("DRIVER=") { + return d.to_string(); + } + } + } + "—".to_string() +} + +fn read_sysfs_mac(ifname: &str) -> Option { + std::fs::read_to_string(format!("/sys/class/net/{ifname}/address")) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Very small fallback: enumerate /sys/class/net +fn sysfs_enum_fallback() -> Vec { + let mut n = Vec::new(); + let Ok(dir) = std::fs::read_dir("/sys/class/net") else { + return n; + }; + for e in dir.flatten() { + let ifname = e.file_name().to_string_lossy().to_string(); + if ifname == "lo" { + continue; + } + n.push(Nic { + name: ifname, + ..Default::default() + }); + } + n +} + +#[cfg(test)] +mod tests { + use super::{build_nics_from_links, IpEntry}; + + #[test] + fn parses_link_and_address_json_with_default_and_management_markers() { + let links: Vec = serde_json::from_str( + r#"[ + {"ifname":"lo","mtu":65536,"operstate":"UNKNOWN","address":"00:00:00:00:00:00"}, + {"ifname":"eth0","mtu":1500,"operstate":"UP","address":"aa:bb:cc:dd:ee:ff"}, + {"ifname":"eth1","mtu":9000,"operstate":"DOWN","address":"11:22:33:44:55:66"} + ]"#, + ) + .unwrap(); + let addrs: Vec = serde_json::from_str( + r#"[ + {"ifname":"eth0","addr_info":[ + {"family":"inet","local":"10.190.15.20","scope":"global"}, + {"family":"inet6","local":"fe80::1","scope":"link"}, + {"family":"inet6","local":"fd00::20","scope":"global"} + ]}, + {"ifname":"eth1","addr_info":[]} + ]"#, + ) + .unwrap(); + + let nics = build_nics_from_links(&links, Some(&addrs), Some("eth0"), Some("eth0")); + assert_eq!(nics.len(), 2); + assert_eq!(nics[0].name, "eth0"); + assert_eq!(nics[0].ipv4, "10.190.15.20"); + assert_eq!(nics[0].ipv6, "fd00::20"); + assert!(nics[0].default_route); + assert!(nics[0].management); + assert_eq!(nics[1].ipv4, "—"); + } +} diff --git a/crates/kcore-console/src/inventory/route.rs b/crates/kcore-console/src/inventory/route.rs new file mode 100644 index 0000000..cb5283c --- /dev/null +++ b/crates/kcore-console/src/inventory/route.rs @@ -0,0 +1,48 @@ +//! Default-route interface from `/proc/net/route` (IPv4). + +/// Returns the interface name that carries the lowest-metric IPv4 default route, if any. +pub fn default_route_ifname(route_table: &str) -> Option { + let mut best_metric: u32 = u32::MAX; + let mut best: Option = None; + for line in route_table.lines().skip(1) { + let p: Vec<&str> = line.split_whitespace().collect(); + if p.len() < 8 { + continue; + } + if p[1] != "00000000" { + continue; + } + let ifname = p[0].to_string(); + let metric = p[6].parse::().unwrap_or(0); + if metric < best_metric { + best_metric = metric; + best = Some(ifname); + } else if metric == best_metric && best.is_none() { + best = Some(ifname); + } + } + best +} + +/// Reads `/proc/net/route` and returns the default interface name. +pub fn read_default_ifname() -> Option { + std::fs::read_to_string("/proc/net/route") + .ok() + .and_then(|s| default_route_ifname(&s)) +} + +#[cfg(test)] +mod tests { + use super::default_route_ifname; + + const FIXTURE: &str = "Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 0101A8C0 0003 0 0 0 00000000 0 0 0 +eth1 00000000 00000000 0001 0 0 5 00000000 0 0 0 +"; + + #[test] + fn picks_lowest_metric_default() { + let ifn = default_route_ifname(FIXTURE); + assert_eq!(ifn.as_deref(), Some("eth0")); + } +} diff --git a/crates/kcore-console/src/inventory/system.rs b/crates/kcore-console/src/inventory/system.rs new file mode 100644 index 0000000..ba92a85 --- /dev/null +++ b/crates/kcore-console/src/inventory/system.rs @@ -0,0 +1,237 @@ +//! Hostname, uptime, version, management URL, and optional kcore cluster metadata. + +use chrono::Local; +use std::time::Duration; + +use super::api::ApiStatus; + +/// Build-time optional git short hash. +pub const GIT_SHORT: &str = match option_env!("KCORE_GIT_REV") { + Some(s) => s, + None => "", +}; + +const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Clone)] +pub struct Meta { + pub product: String, + pub version: String, + pub build_id: String, + pub hostname: String, + pub uptime: Duration, + pub uptime_str: String, + pub local_time: String, + pub management_url: String, + pub api_endpoint: String, + pub api_status: ApiStatus, + pub cluster_name: String, + pub node_role: String, + pub health: Health, + pub local_login: &'static str, + pub remote_hint: String, +} + +impl Default for Meta { + fn default() -> Self { + Self { + product: "kcore hypervisor".to_string(), + version: PKG_VERSION.to_string(), + build_id: if GIT_SHORT.is_empty() { + "—".to_string() + } else { + GIT_SHORT.to_string() + }, + hostname: "loading".to_string(), + uptime: Duration::from_secs(0), + uptime_str: "—".to_string(), + local_time: "—".to_string(), + management_url: "—".to_string(), + api_endpoint: "127.0.0.1:9091".to_string(), + api_status: ApiStatus::Unavailable, + cluster_name: "—".to_string(), + node_role: "—".to_string(), + health: Health::Unknown, + local_login: "disabled", + remote_hint: "kcorectl login https://:8443".to_string(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Health { + Ok, + Degraded, + Unknown, + Critical, +} + +impl std::fmt::Display for Health { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Health::Ok => "OK", + Health::Degraded => "Degraded", + Health::Unknown => "Unknown", + Health::Critical => "Critical", + }; + write!(f, "{s}") + } +} + +fn env(name: &str) -> Option { + std::env::var(name).ok().filter(|s| !s.is_empty()) +} + +fn file_first_line(p: &str) -> Option { + std::fs::read_to_string(p) + .ok() + .and_then(|s| s.lines().next().map(|l| l.trim().to_string())) + .filter(|s| !s.is_empty()) +} + +fn run_ip_json() -> Option { + let out = std::process::Command::new("ip") + .args(["-j", "address", "show"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + serde_json::from_slice(&out.stdout).ok() +} + +fn parse_primary_ipv4(v: &serde_json::Value, default_if: &str) -> Option { + let arr = v.as_array()?; + for iface in arr { + if iface.get("ifname")?.as_str()? != default_if { + continue; + } + for a in iface.get("addr_info")?.as_array()? { + if a.get("family")?.as_str()? != "inet" { + continue; + } + if a.get("scope")?.as_str()? == "global" { + return a.get("local")?.as_str().map(String::from); + } + } + } + None +} + +/// Primary IPv4 for management URL, best-effort. +fn primary_ipv4_for_mgmt() -> Option { + if let Some(ip) = env("KCORE_MANAGEMENT_IP") { + return Some(ip); + } + let ifname = super::route::read_default_ifname()?; + let j = run_ip_json()?; + parse_primary_ipv4(&j, &ifname) +} + +fn format_duration(d: Duration) -> String { + let s = d.as_secs(); + let days = s / 86400; + let h = (s % 86400) / 3600; + let m = (s % 3600) / 60; + if days > 0 { + format!("{days}d {h}h {m}m") + } else if h > 0 { + format!("{h}h {m}m") + } else { + format!("{m}m {sec}s", sec = s % 60) + } +} + +/// Snapshot metadata. Tolerates missing /proc, tools, and files. +pub fn load_meta(api: &ApiStatus) -> Meta { + let hostname = std::fs::read_to_string("/etc/hostname") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "unknown".into()); + + let mut uptime = Duration::from_secs(0); + if let Ok(c) = std::fs::read_to_string("/proc/uptime") { + if let Some(f) = c.split_whitespace().next() { + if let Ok(s) = f.parse::() { + uptime = Duration::from_secs_f64(s); + } + } + } + let uptime_str = format_duration(uptime); + + let kcore_version = std::fs::read_to_string("/run/kcore/version") + .or_else(|_| std::fs::read_to_string("/etc/kcore/version")) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + let version = if !kcore_version.is_empty() { + kcore_version + } else { + PKG_VERSION.to_string() + }; + + let build_id = if !GIT_SHORT.is_empty() { + GIT_SHORT.to_string() + } else { + "—".to_string() + }; + + let cluster_name = env("KCORE_CLUSTER_NAME") + .or_else(|| file_first_line("/etc/kcore/cluster_name")) + .unwrap_or_else(|| "—".to_string()); + + let node_role = env("KCORE_NODE_ROLE") + .or_else(|| file_first_line("/etc/kcore/node_role")) + .unwrap_or_else(|| "—".to_string()); + + let management_url = if let Some(u) = env("KCORE_MANAGEMENT_URL") { + u + } else if let Some(ip) = primary_ipv4_for_mgmt() { + let port = env("KCORE_MANAGEMENT_PORT").unwrap_or_else(|| "8443".into()); + format!("https://{ip}:{port}") + } else { + "—".to_string() + }; + + let health = match api { + ApiStatus::Reachable { healthy } if *healthy => Health::Ok, + ApiStatus::Reachable { .. } => Health::Degraded, + _ => Health::Unknown, + }; + let api_endpoint = format!( + "127.0.0.1:{}", + env("KCORE_API_PORT").unwrap_or_else(|| super::api::KCORE_API_PORT.to_string()) + ); + + let local_time = Local::now().format("%Y-%m-%d %H:%M:%S (local)").to_string(); + + let product = "kcore hypervisor".to_string(); + let remote = if management_url == "—" { + "kcorectl login https://:8443".to_string() + } else { + let with_scheme = + if management_url.starts_with("https://") || management_url.starts_with("http://") { + management_url.clone() + } else { + format!("https://{}", management_url.trim_start_matches('/')) + }; + format!("kcorectl login {with_scheme}") + }; + + Meta { + product, + version, + build_id, + hostname, + uptime, + uptime_str, + local_time, + management_url, + api_endpoint, + api_status: api.clone(), + cluster_name, + node_role, + health, + local_login: "disabled", + remote_hint: remote, + } +} diff --git a/crates/kcore-console/src/lib.rs b/crates/kcore-console/src/lib.rs new file mode 100644 index 0000000..443c46e --- /dev/null +++ b/crates/kcore-console/src/lib.rs @@ -0,0 +1,171 @@ +//! kcore hypervisor appliance console (Ratatui). + +pub mod app; +pub mod inventory; +pub mod theme; +pub mod ui; + +mod tty; + +use std::io::{self, stdout}; +use std::sync::mpsc; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use crate::app::{AppState, Page}; +use crate::inventory::load_snapshot; + +/// CLI options (also used from tests). +#[derive(Debug, Clone, Default)] +pub struct Options { + pub dev: bool, + /// When set, make this path the controlling TTY (dup2 on Unix). + pub tty: Option, +} + +/// Main event loop. Returns `Ok(())` on clean exit (dev only) or `Err` on I/O errors. +pub fn run(opts: Options) -> io::Result<()> { + if let Some(ref p) = opts.tty { + tty::attach_tty(p)?; + } + + let mut stdout = stdout(); + enable_raw_mode()?; + execute!(stdout, EnterAlternateScreen, crossterm::cursor::Hide)?; + + install_panic_hook(!opts.dev); + + if !opts.dev { + let _ = ctrlc::set_handler(|| { + // production: ignore SIGINT + }); + } + + let (refresh_tx, refresh_rx) = mpsc::channel::<()>(); + let (snapshot_tx, snapshot_rx) = mpsc::channel(); + let _inventory_worker = std::thread::spawn(move || loop { + let snapshot = load_snapshot(); + if snapshot_tx.send(snapshot).is_err() { + break; + } + + match refresh_rx.recv_timeout(Duration::from_secs(5)) { + Ok(()) | Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + }); + + let mut app = AppState::new(opts.dev, crate::inventory::Snapshot::default()); + app.clamp_network_selection(); + app.clamp_storage_selection(); + if opts.dev { + eprintln!("[kcore-console] dev mode: q quits, Ctrl+C exits (best effort)"); + } + + let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?; + loop { + while let Ok(snapshot) = snapshot_rx.try_recv() { + app.snapshot = snapshot; + app.clamp_network_selection(); + app.clamp_storage_selection(); + } + + terminal.draw(|f| { + use crate::ui::draw; + draw(f, &app); + })?; + + if event::poll(Duration::from_millis(200))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if key.code == KeyCode::Char('c') + && key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + if opts.dev { + break; + } + } else { + match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') if opts.dev => { + break; + } + KeyCode::Char('r') | KeyCode::Char('R') => { + let _ = refresh_tx.send(()); + } + KeyCode::Tab | KeyCode::Right => { + app.page = app.page.next(); + } + KeyCode::BackTab | KeyCode::Left => { + app.page = app.page.prev(); + } + KeyCode::Char('1') => app.page = Page::Overview, + KeyCode::Char('2') => app.page = Page::Network, + KeyCode::Char('3') => app.page = Page::Storage, + KeyCode::Char('4') => app.page = Page::Diagnostics, + KeyCode::Char('5') => app.page = Page::Help, + KeyCode::Char('?') | KeyCode::Char('h') | KeyCode::Char('H') => { + app.page = Page::Help; + } + KeyCode::Esc => { + app.page = Page::Overview; + } + KeyCode::Down => { + if app.page == Page::Network { + let n = app.snapshot.nics.len(); + if n > 0 { + app.network_sel = (app.network_sel + 1) % n; + } + } else if app.page == Page::Storage { + let n = app.snapshot.disks.len(); + if n > 0 { + app.storage_sel = (app.storage_sel + 1) % n; + } + } + } + KeyCode::Up => { + if app.page == Page::Network { + let n = app.snapshot.nics.len(); + if n > 0 { + app.network_sel = (app.network_sel + n - 1) % n; + } + } else if app.page == Page::Storage { + let n = app.snapshot.disks.len(); + if n > 0 { + app.storage_sel = (app.storage_sel + n - 1) % n; + } + } + } + _ => {} + } + } + } + } + } + } + + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + crossterm::cursor::Show + )?; + disable_raw_mode()?; + Ok(()) +} + +fn install_panic_hook(_production: bool) { + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = crossterm::execute!(io::stderr(), LeaveAlternateScreen, crossterm::cursor::Show); + prev(info); + })); +} diff --git a/crates/kcore-console/src/main.rs b/crates/kcore-console/src/main.rs new file mode 100644 index 0000000..fe4801c --- /dev/null +++ b/crates/kcore-console/src/main.rs @@ -0,0 +1,27 @@ +//! kcore hypervisor appliance TUI (Ratatui / crossterm). + +use clap::Parser; + +/// kcore local appliance console: read-only status, no local shell. +#[derive(Parser, Debug)] +#[command(name = "kcore-console", version, about = "kcore hypervisor appliance TUI", long_about = None)] +struct Cli { + /// Development: allow q / Ctrl+C to exit; print extra diagnostics to stderr. + #[arg(long)] + dev: bool, + /// Attach to this TTY (dup2 stdin/out/err). Typical: /dev/tty1 under systemd. + #[arg(long, value_name = "PATH")] + tty: Option, +} + +fn main() { + let cli = Cli::parse(); + let opts = kcore_console::Options { + dev: cli.dev, + tty: cli.tty, + }; + if let Err(e) = kcore_console::run(opts) { + eprintln!("kcore-console: {e}"); + std::process::exit(1); + } +} diff --git a/crates/kcore-console/src/theme.rs b/crates/kcore-console/src/theme.rs new file mode 100644 index 0000000..9c16c9f --- /dev/null +++ b/crates/kcore-console/src/theme.rs @@ -0,0 +1,49 @@ +//! Color palette: dark background, kcore indigo accent, health colors. + +use ratatui::style::{Color, Modifier, Style}; + +pub fn bg() -> Color { + Color::Rgb(12, 18, 40) +} + +pub fn accent() -> Color { + // kcorehypervisor.com --accent-primary (#6366f1) + Color::Rgb(99, 102, 241) +} + +pub fn text() -> Color { + Color::Rgb(230, 233, 240) +} + +pub fn muted() -> Color { + Color::Rgb(140, 150, 170) +} + +pub fn good() -> Color { + Color::Rgb(80, 200, 120) +} + +pub fn warn() -> Color { + Color::Rgb(255, 200, 100) +} + +pub fn bad() -> Color { + Color::Rgb(255, 100, 100) +} + +pub fn title_style() -> Style { + Style::default().fg(accent()).add_modifier(Modifier::BOLD) +} + +pub fn health_style(s: &str) -> Style { + let c = if s == "OK" { + good() + } else if s == "Degraded" { + warn() + } else if s == "Critical" { + bad() + } else { + warn() + }; + Style::default().fg(c).add_modifier(Modifier::BOLD) +} diff --git a/crates/kcore-console/src/tty.rs b/crates/kcore-console/src/tty.rs new file mode 100644 index 0000000..b65e9f1 --- /dev/null +++ b/crates/kcore-console/src/tty.rs @@ -0,0 +1,28 @@ +//! Optional attach to a TTY path (e.g. `/dev/tty1`). + +use std::fs::OpenOptions; + +/// Duplicate an open TTY to stdin/stdout/stderr so crossterm and Ratatui use it. +pub fn attach_tty(tty: &str) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let f = OpenOptions::new().read(true).write(true).open(tty)?; + let fd = f.as_raw_fd(); + unsafe { + for stream in [0, 1, 2] { + if libc::dup2(fd, stream) < 0 { + return Err(std::io::Error::last_os_error()); + } + } + } + } + #[cfg(not(unix))] + { + let _ = tty; + return Err(std::io::Error::other( + "--tty is only supported on Unix-like systems", + )); + } + Ok(()) +} diff --git a/crates/kcore-console/src/ui/mod.rs b/crates/kcore-console/src/ui/mod.rs new file mode 100644 index 0000000..e0ceb42 --- /dev/null +++ b/crates/kcore-console/src/ui/mod.rs @@ -0,0 +1,2 @@ +mod render; +pub use render::draw; diff --git a/crates/kcore-console/src/ui/render.rs b/crates/kcore-console/src/ui/render.rs new file mode 100644 index 0000000..ae095e0 --- /dev/null +++ b/crates/kcore-console/src/ui/render.rs @@ -0,0 +1,506 @@ +//! Ratatui layout: overview, network, storage, diagnostics, help. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::{Color, Stylize}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs}; +use ratatui::Frame; + +use crate::app::{AppState, Page}; +use crate::inventory::api::ApiStatus; +use crate::inventory::system::Health; +use crate::theme; + +fn health_color(h: &Health) -> Color { + match h { + Health::Ok => theme::good(), + Health::Degraded | Health::Unknown => theme::warn(), + Health::Critical => theme::bad(), + } +} + +fn hlabel(h: &Health) -> &'static str { + match h { + Health::Ok => "OK", + Health::Degraded => "Degraded", + Health::Unknown => "Unknown", + Health::Critical => "Critical", + } +} + +pub fn draw(f: &mut Frame<'_>, app: &AppState) { + let area = f.area(); + let ch = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + if ch.len() < 4 { + return; + } + let brand = ch[0]; + let tabbar = ch[1]; + let body = ch[2]; + let foot = ch[3]; + + let title = Line::from(vec![ + Span::styled( + " kcore hypervisor ", + theme::title_style().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("v{}", app.snapshot.meta.version), + Style::default().fg(theme::muted()), + ), + ]); + f.render_widget( + Paragraph::new(title).block(Block::new().bg(theme::bg())), + brand, + ); + + let tab_ix = app.page as usize; + let tabs = Tabs::new( + ["Overview", "Network", "Storage", "Diagnostics", "Help"] + .iter() + .map(|s| Line::from(Span::styled(*s, Style::default().fg(theme::text())))) + .collect::>(), + ) + .select(tab_ix) + .style(Style::default().fg(theme::muted())) + .highlight_style( + Style::default() + .fg(theme::accent()) + .add_modifier(Modifier::BOLD), + ) + .divider(Span::raw(" │ ")); + f.render_widget( + tabs.block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(theme::accent())), + ), + tabbar, + ); + + let (body, logo) = carve_logo_area(body); + match app.page { + Page::Overview => draw_overview(f, body, app), + Page::Network => draw_network(f, app, body), + Page::Storage => draw_storage(f, app, body), + Page::Diagnostics => draw_diagnostics(f, body, app), + Page::Help => draw_help(f, body, app.dev), + } + if let Some(logo) = logo { + draw_logo(f, logo); + } + + let footer = Line::from(vec![ + Span::styled("Tab/←/→ ", theme::muted()), + Span::styled("r refresh ", theme::muted()), + Span::styled("? help ", theme::muted()), + Span::styled( + if app.dev { + "q quit (dev) " + } else { + "q disabled " + }, + theme::muted(), + ), + ]); + f.render_widget( + Paragraph::new(footer) + .block( + Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(theme::accent())), + ) + .style(Style::default().fg(theme::muted())), + foot, + ); +} + +fn carve_logo_area(area: Rect) -> (Rect, Option) { + const LOGO_WIDTH: u16 = 52; + const LOGO_HEIGHT: u16 = 8; + + if area.width < 64 || area.height < 20 { + return (area, None); + } + + let logo = Rect { + x: area.x + area.width - LOGO_WIDTH, + y: area.y + area.height - LOGO_HEIGHT, + width: LOGO_WIDTH, + height: LOGO_HEIGHT, + }; + let content = Rect { + x: area.x, + y: area.y, + width: area.width, + height: area.height - LOGO_HEIGHT, + }; + (content, Some(logo)) +} + +fn draw_logo(f: &mut Frame<'_>, area: Rect) { + const LOGO: &[&str] = &[ + "██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗", + "██║ ██╔╝██╔════╝ ██╔═══██╗██╔══██╗██╔════╝", + "█████╔╝ ██║ ██║ ██║██████╔╝█████╗ ", + "██╔═██╗ ██║ ██║ ██║██╔══██╗██╔══╝ ", + "██║ ██╗╚██████╗ ╚██████╔╝██║ ██║███████╗", + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝", + ]; + let lines = LOGO + .iter() + .map(|line| { + Line::from(Span::styled( + *line, + Style::default() + .fg(theme::accent()) + .add_modifier(Modifier::BOLD), + )) + }) + .collect::>(); + + f.render_widget(Paragraph::new(lines), area); +} + +fn draw_overview(f: &mut Frame<'_>, area: Rect, app: &AppState) { + let m = &app.snapshot.meta; + let hc = health_color(&m.health); + let lines = vec![ + Line::from(vec![ + Span::styled("Node: ", theme::muted()), + Span::styled(m.hostname.clone(), theme::text()), + ]), + Line::from(vec![ + Span::styled("Version: ", theme::muted()), + Span::styled(m.version.clone(), theme::text()), + Span::raw(" "), + Span::styled("Build: ", theme::muted()), + Span::styled(m.build_id.clone(), theme::text()), + ]), + Line::from(vec![ + Span::styled("Uptime: ", theme::muted()), + Span::styled(m.uptime_str.clone(), theme::text()), + Span::raw(" "), + Span::styled("Time: ", theme::muted()), + Span::styled(m.local_time.clone(), theme::text()), + ]), + Line::from(vec![ + Span::styled("Cluster: ", theme::muted()), + Span::styled(m.cluster_name.clone(), theme::text()), + Span::raw(" "), + Span::styled("Role: ", theme::muted()), + Span::styled(m.node_role.clone(), theme::text()), + ]), + Line::from(vec![ + Span::styled("Health: ", theme::muted()), + Span::styled( + hlabel(&m.health).to_string(), + Style::default().fg(hc).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled("API endpoint: ", theme::muted()), + Span::styled(m.api_endpoint.clone(), theme::text()), + Span::raw(" "), + Span::styled("Status: ", theme::muted()), + Span::styled( + match &m.api_status { + ApiStatus::Unavailable => "unavailable", + ApiStatus::Reachable { .. } => "available", + } + .to_string(), + theme::text(), + ), + ]), + Line::from(vec![ + Span::styled("Management: ", theme::muted()), + Span::styled(m.management_url.clone(), theme::text()), + ]), + Line::from(vec![ + Span::styled("Local login: ", theme::muted()), + Span::styled( + m.local_login.to_string(), + Style::default() + .fg(theme::warn()) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled("Remote: ", theme::muted()), + Span::styled( + m.remote_hint.clone(), + Style::default() + .fg(theme::accent()) + .add_modifier(Modifier::BOLD), + ), + ]), + ]; + f.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title(" Overview ") + .title_style(theme::title_style()) + .border_style(Style::default().fg(theme::accent())), + ), + area, + ); +} + +fn table_header_net() -> Row<'static> { + Row::new(vec![ + Cell::from("Iface"), + Cell::from("State"), + Cell::from("MAC"), + Cell::from("IPv4"), + Cell::from("IPv6"), + Cell::from("MTU"), + Cell::from("Speed"), + Cell::from("Driver"), + Cell::from("def"), + Cell::from("mgmt"), + ]) + .style( + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme::accent()), + ) + .height(1) +} + +fn table_rows_net(app: &AppState) -> Vec> { + app.snapshot + .nics + .iter() + .enumerate() + .map(|(i, n)| { + let style = if i == app.network_sel { + Style::default() + .fg(theme::text()) + .bg(Color::Rgb(26, 36, 58)) + } else { + Style::default().fg(theme::text()) + }; + Row::new(vec![ + Cell::from(n.name.clone()), + Cell::from(n.oper_state.clone()), + Cell::from(n.mac.clone()), + Cell::from(n.ipv4.clone()), + Cell::from(n.ipv6.clone()), + Cell::from(n.mtu.clone()), + Cell::from(n.speed.clone()), + Cell::from(n.driver.clone()), + Cell::from(if n.default_route { "•" } else { "—" }), + Cell::from(if n.management { "•" } else { "—" }), + ]) + .style(style) + }) + .collect() +} + +fn draw_network(f: &mut Frame<'_>, app: &AppState, area: Rect) { + let rows = table_rows_net(app); + let trows = if rows.is_empty() { + vec![Row::new( + (0..10).map(|_| Cell::from("—")).collect::>(), + )] + } else { + rows + }; + let table = Table::new( + trows, + [ + Constraint::Min(6), + Constraint::Min(5), + Constraint::Min(14), + Constraint::Min(10), + Constraint::Min(14), + Constraint::Min(4), + Constraint::Min(10), + Constraint::Min(6), + Constraint::Min(3), + Constraint::Min(3), + ], + ) + .header(table_header_net()); + f.render_widget( + table + .block( + Block::default() + .borders(Borders::ALL) + .title(" Network inventory ") + .title_style(theme::title_style()) + .border_style(Style::default().fg(theme::accent())), + ) + .column_spacing(1), + area, + ); +} + +fn table_header_disk() -> Row<'static> { + Row::new(vec![ + Cell::from("Name"), + Cell::from("Path"), + Cell::from("Model"), + Cell::from("Serial"), + Cell::from("Size"), + Cell::from("Kind"), + Cell::from("RO"), + Cell::from("Mounts"), + Cell::from("Health"), + Cell::from("Role"), + ]) + .style( + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme::accent()), + ) +} + +fn table_rows_disk(app: &AppState) -> Vec> { + app.snapshot + .disks + .iter() + .enumerate() + .map(|(i, d)| { + let style = if i == app.storage_sel { + Style::default() + .fg(theme::text()) + .bg(Color::Rgb(26, 36, 58)) + } else { + Style::default().fg(theme::text()) + }; + Row::new(vec![ + Cell::from(d.name.clone()), + Cell::from(d.path.clone()), + Cell::from(d.model.clone()), + Cell::from(d.serial.clone()), + Cell::from(d.size_text.clone()), + Cell::from(d.kind.clone()), + Cell::from(d.ro.clone()), + Cell::from(d.mountpoints.clone()), + Cell::from(d.health.clone()), + Cell::from(d.usage_role.clone()), + ]) + .style(style) + }) + .collect() +} + +fn draw_storage(f: &mut Frame<'_>, app: &AppState, area: Rect) { + let rows = table_rows_disk(app); + let trows = if rows.is_empty() { + vec![Row::new( + (0..10).map(|_| Cell::from("—")).collect::>(), + )] + } else { + rows + }; + let table = Table::new( + trows, + [ + Constraint::Min(6), + Constraint::Min(8), + Constraint::Min(10), + Constraint::Min(8), + Constraint::Min(9), + Constraint::Min(5), + Constraint::Min(2), + Constraint::Min(12), + Constraint::Min(5), + Constraint::Min(8), + ], + ) + .header(table_header_disk()); + f.render_widget( + table + .block( + Block::default() + .borders(Borders::ALL) + .title(" Storage inventory ") + .title_style(theme::title_style()) + .border_style(Style::default().fg(theme::accent())), + ) + .column_spacing(1), + area, + ); +} + +fn draw_diagnostics(f: &mut Frame<'_>, area: Rect, app: &AppState) { + let mut line = String::new(); + for s in &app.snapshot.diag { + line.push_str(&format!(" {}: {} │", s.name, s.status)); + } + f.render_widget( + Paragraph::new(line).block( + Block::default() + .borders(Borders::ALL) + .title(" kcore services (local) ") + .title_style(theme::title_style()) + .border_style(Style::default().fg(theme::accent())), + ), + area, + ); +} + +fn draw_help(f: &mut Frame<'_>, area: Rect, _dev: bool) { + let t = "kcore hypervisor appliance — local display is read-only.\ + \nUse the management URL and kcorectl from a trusted machine.\ + \nReboot, shutdown, and root shell are intentionally unavailable here.\ + \n\nSecurity: protect boot (UEFI, GRUB), mask extra getty, prefer SSH.\ + \nSee: docs/appliance-console.md in the kcore source tree.\n"; + f.render_widget( + Paragraph::new(t).block( + Block::default() + .borders(Borders::ALL) + .title(" Help ") + .title_style(theme::title_style()) + .border_style(Style::default().fg(theme::accent())), + ), + area, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logo_area_is_reserved_at_bottom_right() { + let area = Rect::new(0, 0, 120, 32); + + let (content, logo) = carve_logo_area(area); + + assert_eq!(content, Rect::new(0, 0, 120, 24)); + assert_eq!(logo, Some(Rect::new(68, 24, 52, 8))); + } + + #[test] + fn logo_area_is_skipped_on_small_consoles() { + let area = Rect::new(0, 0, 63, 20); + + let (content, logo) = carve_logo_area(area); + + assert_eq!(content, area); + assert_eq!(logo, None); + } + + #[test] + fn logo_area_is_available_on_standard_tty_size() { + let area = Rect::new(0, 0, 80, 22); + + let (content, logo) = carve_logo_area(area); + + assert_eq!(content, Rect::new(0, 0, 80, 14)); + assert_eq!(logo, Some(Rect::new(28, 14, 52, 8))); + } +} diff --git a/crates/kcore-disk-layout-yaml/Cargo.toml b/crates/kcore-disk-layout-yaml/Cargo.toml new file mode 100644 index 0000000..17d0de0 --- /dev/null +++ b/crates/kcore-disk-layout-yaml/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kcore-disk-layout-yaml" +version = "0.1.0" +edition = "2021" +description = "Declarative YAML disk layouts for kcore DiskLayout, emitted as disko.devices Nix" + +[dependencies] +serde = { version = "1", features = ["derive"] } +thiserror = "2" + +[lints] +workspace = true diff --git a/crates/kcore-disk-layout-yaml/src/lib.rs b/crates/kcore-disk-layout-yaml/src/lib.rs new file mode 100644 index 0000000..fcac007 --- /dev/null +++ b/crates/kcore-disk-layout-yaml/src/lib.rs @@ -0,0 +1,337 @@ +//! Declarative YAML for [`DiskLayout`](https://github.com/rtacconi/kcore) manifests. +//! +//! The controller and node-agent still consume **Nix** that defines `disko.devices`. +//! This crate turns a structured, reviewed YAML document into that Nix string so +//! operators never hand-author Nix for the common cases. +//! +//! # Example YAML +//! +//! ```yaml +//! spec: +//! nodeId: node-a +//! diskLayout: +//! disks: +//! - name: data1 +//! device: /dev/nvme1n1 +//! gpt: +//! partitions: +//! - name: kcore0 +//! size: "100%" +//! content: +//! type: filesystem +//! format: ext4 +//! mountpoint: /var/lib/kcore/volumes1 +//! ``` + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Top-level `spec.diskLayout` body (under `kind: DiskLayout`). +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DiskLayoutBody { + /// One entry per whole disk (`disko.devices.disk.`). + pub disks: Vec, + /// Optional empty `lvm_vg` stubs (e.g. when partitions use `lvm_pv`). + #[serde(default)] + pub lvm_volume_groups: Vec, + /// Optional empty `zpool` stubs for ZFS member partitions. + #[serde(default)] + pub zfs_pools: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct YamlDisk { + /// Attribute name under `disko.devices.disk` (must be a valid Nix identifier). + pub name: String, + /// Block device path, e.g. `/dev/nvme1n1`. + pub device: String, + pub gpt: YamlGpt, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct YamlGpt { + pub partitions: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct YamlPartition { + pub name: String, + /// e.g. `100%`, `512M` + pub size: String, + pub content: PartitionContent, +} + +/// Partition contents supported for day-2 data disks (disko-compatible). +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum PartitionContent { + Filesystem { + format: String, + mountpoint: String, + }, + #[serde(rename = "lvm_pv")] + LvmPv { + vg: String, + }, + Zfs { + pool: String, + }, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct YamlLvmVg { + pub name: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct YamlZfsPool { + pub name: String, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum EmitError { + #[error("diskLayout.disks must not be empty")] + EmptyDisks, + #[error("disk `{0}`: at least one GPT partition is required")] + NoPartitions(String), + #[error("invalid Nix identifier `{0}`: use letters, digits, underscore; must not start with a digit")] + BadIdentifier(String), + #[error("disk `{0}`: device must be an absolute /dev/ path")] + BadDevicePath(String), + #[error("duplicate disk name `{0}`")] + DuplicateDiskName(String), + #[error("partition `{0}` on disk `{1}`: size must not be empty")] + EmptyPartitionSize(String, String), +} + +/// Emit a Nix expression whose top-level sets `disko.devices` for disko. +pub fn emit_disko_devices_nix(body: &DiskLayoutBody) -> Result { + if body.disks.is_empty() { + return Err(EmitError::EmptyDisks); + } + let mut seen = std::collections::BTreeSet::new(); + for d in &body.disks { + if !seen.insert(d.name.clone()) { + return Err(EmitError::DuplicateDiskName(d.name.clone())); + } + validate_ident(&d.name)?; + validate_dev_path(&d.device)?; + if d.gpt.partitions.is_empty() { + return Err(EmitError::NoPartitions(d.name.clone())); + } + for p in &d.gpt.partitions { + validate_ident(&p.name)?; + if p.size.trim().is_empty() { + return Err(EmitError::EmptyPartitionSize( + p.name.clone(), + d.name.clone(), + )); + } + match &p.content { + PartitionContent::Filesystem { mountpoint, .. } => { + if mountpoint.is_empty() { + return Err(EmitError::BadIdentifier(mountpoint.clone())); + } + } + PartitionContent::LvmPv { vg } => validate_ident(vg)?, + PartitionContent::Zfs { pool } => validate_ident(pool)?, + } + } + } + for v in &body.lvm_volume_groups { + validate_ident(&v.name)?; + } + for z in &body.zfs_pools { + validate_ident(&z.name)?; + } + + let mut out = String::new(); + out.push_str("{ disko.devices = {\n"); + out.push_str(" disk = {\n"); + for disk in &body.disks { + out.push_str(&format!(" {} = {{\n", disk.name)); + out.push_str(" type = \"disk\";\n"); + out.push_str(&format!(" device = {};\n", nix_string(&disk.device))); + out.push_str(" content = {\n"); + out.push_str(" type = \"gpt\";\n"); + out.push_str(" partitions = {\n"); + for part in &disk.gpt.partitions { + out.push_str(&format!(" {} = {{\n", part.name)); + out.push_str(&format!(" size = {};\n", nix_string(&part.size))); + out.push_str(" content = "); + out.push_str(&emit_partition_content(&part.content)?); + out.push_str(";\n"); + out.push_str(" };\n"); + } + out.push_str(" };\n"); + out.push_str(" };\n"); + out.push_str(" };\n"); + } + out.push_str(" };\n"); + + if !body.lvm_volume_groups.is_empty() { + out.push_str(" lvm_vg = {\n"); + for vg in &body.lvm_volume_groups { + out.push_str(&format!(" {} = {{\n", vg.name)); + out.push_str(" type = \"lvm_vg\";\n"); + out.push_str(" lvs = { };\n"); + out.push_str(" };\n"); + } + out.push_str(" };\n"); + } + + if !body.zfs_pools.is_empty() { + out.push_str(" zpool = {\n"); + for pool in &body.zfs_pools { + out.push_str(&format!(" {} = {{\n", pool.name)); + out.push_str(" type = \"zpool\";\n"); + out.push_str(" datasets = { };\n"); + out.push_str(" };\n"); + } + out.push_str(" };\n"); + } + + out.push_str("}; }"); + Ok(out) +} + +fn emit_partition_content(c: &PartitionContent) -> Result { + Ok(match c { + PartitionContent::Filesystem { format, mountpoint } => { + format!( + "{{ type = \"filesystem\"; format = {}; mountpoint = {}; }}", + nix_string(format), + nix_string(mountpoint) + ) + } + PartitionContent::LvmPv { vg } => { + format!("{{ type = \"lvm_pv\"; vg = {}; }}", nix_string(vg)) + } + PartitionContent::Zfs { pool } => { + format!("{{ type = \"zfs\"; pool = {}; }}", nix_string(pool)) + } + }) +} + +fn validate_ident(s: &str) -> Result<(), EmitError> { + let mut chars = s.chars(); + let Some(first) = chars.next() else { + return Err(EmitError::BadIdentifier(s.to_string())); + }; + if !(first.is_ascii_alphabetic() || first == '_') { + return Err(EmitError::BadIdentifier(s.to_string())); + } + if first.is_ascii_digit() { + return Err(EmitError::BadIdentifier(s.to_string())); + } + for ch in chars { + if !(ch.is_ascii_alphanumeric() || ch == '_') { + return Err(EmitError::BadIdentifier(s.to_string())); + } + } + Ok(()) +} + +fn validate_dev_path(s: &str) -> Result<(), EmitError> { + if !s.starts_with("/dev/") || s.len() < 6 { + return Err(EmitError::BadDevicePath(s.to_string())); + } + Ok(()) +} + +/// Escape a string for use inside Nix double quotes. +fn nix_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_ascii() => out.push(c), + c => out.push_str(&format!("\\u{{{:x}}}", c as u32)), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_ext4() -> DiskLayoutBody { + DiskLayoutBody { + disks: vec![YamlDisk { + name: "data1".into(), + device: "/dev/nvme1n1".into(), + gpt: YamlGpt { + partitions: vec![YamlPartition { + name: "kcore0".into(), + size: "100%".into(), + content: PartitionContent::Filesystem { + format: "ext4".into(), + mountpoint: "/var/lib/kcore/volumes1".into(), + }, + }], + }, + }], + lvm_volume_groups: vec![], + zfs_pools: vec![], + } + } + + #[test] + fn emit_contains_disko_devices_and_device() { + let nix = emit_disko_devices_nix(&minimal_ext4()).unwrap(); + assert!(nix.contains("disko.devices")); + assert!(nix.contains("device = \"/dev/nvme1n1\"")); + assert!(nix.contains("type = \"filesystem\"")); + assert!(nix.contains("/var/lib/kcore/volumes1")); + } + + #[test] + fn extract_target_devices_compatible() { + let nix = emit_disko_devices_nix(&minimal_ext4()).unwrap(); + // kcore-disko-types extractor keys off `device = "/dev/...` + assert!(nix.contains("device = \"/dev/nvme1n1\"")); + } + + #[test] + fn rejects_invalid_disk_name() { + let mut b = minimal_ext4(); + b.disks[0].name = "123bad".into(); + assert!(emit_disko_devices_nix(&b).is_err()); + } + + #[test] + fn lvm_vg_and_zpool_emit() { + let body = DiskLayoutBody { + disks: vec![YamlDisk { + name: "data1".into(), + device: "/dev/sdb".into(), + gpt: YamlGpt { + partitions: vec![YamlPartition { + name: "pv0".into(), + size: "100%".into(), + content: PartitionContent::LvmPv { + vg: "vg_kcore".into(), + }, + }], + }, + }], + lvm_volume_groups: vec![YamlLvmVg { + name: "vg_kcore".into(), + }], + zfs_pools: vec![], + }; + let nix = emit_disko_devices_nix(&body).unwrap(); + assert!(nix.contains("lvm_vg")); + assert!(nix.contains("lvm_pv")); + } +} diff --git a/crates/kctl/Cargo.toml b/crates/kctl/Cargo.toml index 8b75003..40df57d 100644 --- a/crates/kctl/Cargo.toml +++ b/crates/kctl/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] clap = { version = "4", features = ["derive"] } kcore-sanitize = { path = "../kcore-sanitize" } +kcore-disk-layout-yaml = { path = "../kcore-disk-layout-yaml" } dirs = "6" prost = "0.13" prost-types = "0.13" diff --git a/crates/kctl/src/commands/disk_layout.rs b/crates/kctl/src/commands/disk_layout.rs index 15ae60a..e7f6bdb 100644 --- a/crates/kctl/src/commands/disk_layout.rs +++ b/crates/kctl/src/commands/disk_layout.rs @@ -15,10 +15,14 @@ //! `layoutNixFile` is accepted as a shortcut to read the Nix body from a //! file next to the manifest, so operators don't have to inline a //! several-KB-long expression. +//! +//! `diskLayout` holds a structured YAML description (`kcore-disk-layout-yaml`) +//! that `kctl` expands to the same `disko.devices` Nix the controller stores. use std::path::Path; use anyhow::{bail, Context, Result}; +use kcore_disk_layout_yaml::emit_disko_devices_nix; use serde::Deserialize; use crate::apply_summary::render_apply_summary; @@ -47,6 +51,8 @@ struct DiskLayoutSpec { layout_nix: String, #[serde(default)] layout_nix_file: String, + #[serde(default)] + disk_layout: Option, } pub async fn apply_from_file(info: &ConnectionInfo, file: &str) -> Result<()> { @@ -226,21 +232,30 @@ fn parse_manifest(file: &str) -> Result { fn resolve_layout_nix(manifest_path: &str, spec: &DiskLayoutSpec) -> Result { let has_inline = !spec.layout_nix.trim().is_empty(); let has_file = !spec.layout_nix_file.trim().is_empty(); - match (has_inline, has_file) { - (true, true) => { - bail!("spec.layoutNix and spec.layoutNixFile are mutually exclusive; pick one") - } - (true, false) => Ok(spec.layout_nix.clone()), - (false, true) => { - let base = Path::new(manifest_path) - .parent() - .unwrap_or_else(|| Path::new(".")); - let full = base.join(spec.layout_nix_file.trim()); - std::fs::read_to_string(&full) - .with_context(|| format!("reading layoutNixFile {}", full.display())) + let has_yaml = spec.disk_layout.is_some(); + let n = u8::from(has_inline) + u8::from(has_file) + u8::from(has_yaml); + if n != 1 { + if n == 0 { + bail!( + "spec must set exactly one of: diskLayout (structured YAML), layoutNix, or layoutNixFile" + ); } - (false, false) => bail!("one of spec.layoutNix or spec.layoutNixFile is required"), + bail!( + "spec.diskLayout, spec.layoutNix, and spec.layoutNixFile are mutually exclusive; pick one" + ); + } + if let Some(body) = &spec.disk_layout { + return emit_disko_devices_nix(body).map_err(|e| anyhow::anyhow!(e)); } + if has_inline { + return Ok(spec.layout_nix.clone()); + } + let base = Path::new(manifest_path) + .parent() + .unwrap_or_else(|| Path::new(".")); + let full = base.join(spec.layout_nix_file.trim()); + std::fs::read_to_string(&full) + .with_context(|| format!("reading layoutNixFile {}", full.display())) } fn phase_str(phase: i32) -> &'static str { @@ -311,9 +326,10 @@ spec: node_id: "n".to_string(), layout_nix: String::new(), layout_nix_file: String::new(), + disk_layout: None, }; let err = resolve_layout_nix("/tmp/does-not-exist", &spec).unwrap_err(); - assert!(format!("{err:#}").contains("one of spec.layoutNix")); + assert!(format!("{err:#}").contains("diskLayout")); } #[test] @@ -322,6 +338,7 @@ spec: node_id: "n".to_string(), layout_nix: "disko.devices = {};".to_string(), layout_nix_file: "disk.nix".to_string(), + disk_layout: None, }; let err = resolve_layout_nix("/tmp/does-not-exist", &spec).unwrap_err(); assert!(format!("{err:#}").contains("mutually exclusive")); @@ -338,8 +355,40 @@ spec: node_id: "n".to_string(), layout_nix: String::new(), layout_nix_file: "disk.nix".to_string(), + disk_layout: None, }; let got = resolve_layout_nix(manifest_path.to_str().unwrap(), &spec).unwrap(); assert!(got.contains("disko.devices")); } + + #[test] + fn manifest_parses_disk_layout_yaml() { + let manifest = r#" +kind: DiskLayout +metadata: + name: ssd-pool +spec: + nodeId: node-a + diskLayout: + disks: + - name: data1 + device: /dev/nvme1n1 + gpt: + partitions: + - name: kcore0 + size: "100%" + content: + type: filesystem + format: ext4 + mountpoint: /var/lib/kcore/volumes1 +"#; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dl.yaml"); + std::fs::write(&path, manifest).unwrap(); + let got = parse_manifest(path.to_str().unwrap()).unwrap(); + assert!(got.spec.disk_layout.is_some()); + let nix = resolve_layout_nix(path.to_str().unwrap(), &got.spec).unwrap(); + assert!(nix.contains("disko.devices")); + assert!(nix.contains("/dev/nvme1n1")); + } } diff --git a/crates/kctl/src/main.rs b/crates/kctl/src/main.rs index f3dea38..2982598 100644 --- a/crates/kctl/src/main.rs +++ b/crates/kctl/src/main.rs @@ -147,8 +147,9 @@ enum Command { dry_run: bool, }, /// Show a controller-side pre-flight diff/safety report for a manifest. - /// Currently supports `kind: DiskLayout` manifests; other kinds will be - /// added as their classifiers land. + /// Currently supports `kind: DiskLayout` manifests (`spec.diskLayout` YAML, + /// or `layoutNix` / `layoutNixFile`); other kinds will be added as their + /// classifiers land. Diff { /// Path to the manifest file #[arg(short = 'f', long = "filename")] diff --git a/docs/README.md b/docs/README.md index ea3442b..7072529 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ | Topic | Document | |--------|-----------| | Architecture | [Architecture.md](./Architecture.md) | +| Appliance console | [appliance-console.md](./appliance-console.md) | | Day-2 filesystem operations | [day-2-filesystem-operations.md](./day-2-filesystem-operations.md) | | CRDT / HA replication | [ha-crdt-replication.md](./ha-crdt-replication.md) | | File layout | [file-structure.md](./file-structure.md) | @@ -29,4 +30,4 @@ ## User-facing guides -See **[kcorehypervisor.com/docs/user/](https://kcorehypervisor.com/docs/user/)** — the canonical location for operator-focused guides (no duplicate copies in this tree). +See **[kcorehypervisor.com/docs/user/](https://kcorehypervisor.com/docs/user/)** — the canonical location for operator-focused guides. The local index for those pages is in [`docs/user/`](./user/README.md). diff --git a/docs/appliance-console.md b/docs/appliance-console.md new file mode 100644 index 0000000..ad0fdef --- /dev/null +++ b/docs/appliance-console.md @@ -0,0 +1,134 @@ +# kcore appliance local console (Ratatui) + +kcore nodes ship a **read-only, full-screen TUI** on the primary virtual +terminal (`/dev/tty1`) in place of a local shell login. Administration is +intended to happen over **SSH** and the **remote control plane** (`kcorectl` / API). + +- **Implementation:** `crates/kcore-console` (Ratatui + crossterm) +- **NixOS / kcoreOS:** `modules/kcore-branding.nix` enables + `systemd.services.kcore-console` and disables agetty/autovt +- **Reference unit (other distros / inspection):** `packaging/systemd/kcore-console.service` + +## Disabling getty and autovt + +Hiding the classic Linux login is **not** limited to `getty@tty1`. NixOS also +wires the first virtual console through **`autovt@tty1`**. You should mask +**all** of the following so operators cannot `Ctrl+Alt+F2`–`F6` into a login +getty and obtain a local shell (unless you deliberately re-enable a rescue path): + +```bash +systemctl stop getty@tty1.service 2>/dev/null || true +for i in 1 2 3 4 5 6; do + systemctl mask "getty@tty${i}.service" 2>/dev/null || true +done +systemctl mask 'autovt@.service' 2>/dev/null || true +``` + +Then enable the appliance console and reload: + +```bash +systemctl enable kcore-console.service +systemctl daemon-reload +systemctl start kcore-console.service +``` + +NixOS declarations (see `kcore-branding.nix`) already set +`systemd.services."getty@tty*".enable = false` and +`systemd.services."autovt@".enable = false`. + +## Development vs production + +| Mode | Invocation | Quit | +| --- | --- | --- | +| Development | `kcore-console --dev` (or `cargo run -p kcore-console -- --dev`) | `q` and `Ctrl+C` exit the process. | +| Production (default) | `kcore-console` or with `--tty /dev/tty1` under systemd | `q` and `Ctrl+C` are ignored so the TUI never drops to a local shell. | + +`systemd` is configured with `Restart=always` so the TUI is restarted on crash +or exit. + +## Operator usage + +On a running kcore node, the physical or virtual console should display the +appliance TUI automatically on `tty1`. + +- Use **Tab**, **Right**, or **Left** to switch between pages. +- Use **Up** and **Down** to move the highlighted row inside Network and + Storage tables. +- Press **r** to refresh inventory immediately. +- Press **?** or **h** for Help. +- In production, **q** and **Ctrl+C** are intentionally ignored. + +The console pages are: + +| Page | Purpose | +| --- | --- | +| Overview | Product, node, version, API endpoint, management URL, health, uptime, local-login status | +| Network | NIC inventory: interface, state, MAC, IPv4/IPv6, MTU, speed, driver, default-route and management markers | +| Storage | Disk inventory: device, path, model, serial, size, SSD/HDD/NVMe, read-only flag, mountpoints, health, role | +| Diagnostics | Local kcore service status for node-agent, controller, and dashboard | +| Help | Security model and operator reminders | + +This screen is not a recovery shell. If the TUI shows `API: unavailable`, keep +using SSH or out-of-band management for troubleshooting; the console will still +render local NIC and disk inventory where Linux can provide it. + +## Quiet boot (GRUB) + +To reduce serial / framebuffer noise during boot, set in `/etc/default/grub` +(or the equivalent in your image): + +```bash +GRUB_CMDLINE_LINUX_DEFAULT="quiet loglevel=3 systemd.show_status=false" +``` + +Regenerate the GRUB config (Debian/Ubuntu style): + +```bash +sudo update-grub +``` + +NixOS: + +```nix +boot.kernelParams = [ "quiet" "loglevel=3" "systemd.show_status=false" ]; +``` + +Optional: **Plymouth** can be enabled later for a vendor splash; this is +orthogonal to the TTY console. + +## Bootloader and recovery hardening (production) + +A real **appliance** should not allow trivial recovery or kernel bypass: + +- UEFI **firmware** password; disable legacy boot and unused boot options +- **GRUB** password; disable **editor** and **single-user** boot unless required +- **Secure Boot** where the distribution supports it and you can maintain keys +- Avoid `init=/bin/sh` and similar in kernel parameters in production +- For IPMI, restrict serial-over-LAN to trusted management networks + +kcore will document operator workflows separately; the **local TUI only shows +status** and is not a privileged management shell. + +## Security model (summary) + +- The appliance console is **read-only** from an operator’s perspective: no + local login, no shell, no in-band reboot or power actions from the TUI in + this first implementation. +- **Reboot** / **shutdown** / break-glass should be **remote** (authenticated + API) or a **separate, audited recovery path** (e.g. recovery mode with + one-time token). +- The node-agent gRPC on `127.0.0.1:9091` is probed for a simple “API: + available” line; a failure still allows the TUI to run. + +## Environment variables (optional) + +| Variable | Effect | +| --- | --- | +| `KCORE_MANAGEMENT_URL` | Full management URL; overrides derived `https://ip:port` | +| `KCORE_MANAGEMENT_IP` / `KCORE_MANAGEMENT_PORT` | Build default management URL | +| `KCORE_CLUSTER_NAME` / `KCORE_NODE_ROLE` | Shown on the Overview page | +| `KCORE_MGMT_IFACE` | Mark management NIC when default route is ambiguous | +| `KCORE_API_PORT` | Override local API port (default 9091) for reachability checks | + +`KCORE_GIT_REV` is set at **build** time in Nix (see `flake.nix`) to populate the +**Build** field when available. diff --git a/docs/day-2-filesystem-operations.md b/docs/day-2-filesystem-operations.md index d9b4669..9a33eb0 100644 --- a/docs/day-2-filesystem-operations.md +++ b/docs/day-2-filesystem-operations.md @@ -65,13 +65,28 @@ The controller is never expected to drain, stop, migrate, or reboot VMs. The ope Day-2 disk changes are best driven through the controller as a `kind: DiskLayout` resource instead of direct node pushes. The controller persists the manifest in its replicated DB, classifier-pre-flights it, and the controller-side reconciler dispatches `ApplyDiskLayout` to the owning node and writes the result back into `status`. +Structured YAML (`spec.diskLayout`) is expanded by `kctl` to `disko.devices` +Nix before it reaches the controller. Alternatively, set `layoutNix` / +`layoutNixFile` for full disko expressiveness — **exactly one** of the three. + ```yaml kind: DiskLayout metadata: name: prod-data-pool spec: nodeId: node-prod-01 - layoutNixFile: ./day2-disk.nix # or inline `layoutNix: |` + diskLayout: + disks: + - name: data1 + device: /dev/nvme1n1 + gpt: + partitions: + - name: kcore0 + size: "100%" + content: + type: filesystem + format: ext4 + mountpoint: /var/lib/kcore/volumes1 ``` ```bash @@ -84,7 +99,7 @@ kctl delete disk-layout prod-data-pool # removes from controller DB; node `kctl diff` calls the controller's read-only `ClassifyDiskLayout` RPC, which extracts the target devices from the Nix body and runs the controller-side pre-flight (structural checks today; live inventory once the replicated block-device table lands). The node-agent classifier is still the authoritative gate on every apply. -The reconciler retries refused layouts on every tick using the same generation, so the operator drains affected VMs and re-checks `kctl describe disk-layout ` until `phase = applied`. Editing `spec.layoutNix` (or its referenced file) bumps the generation; resubmitting the identical content does not. +The reconciler retries refused layouts on every tick using the same generation, so the operator drains affected VMs and re-checks `kctl describe disk-layout ` until `phase = applied`. Editing `spec.diskLayout`, `spec.layoutNix`, or the referenced `layoutNixFile` bumps the generation when the resolved Nix body changes; resubmitting the identical content does not. `kctl node apply-disk -f …` remains available for one-off operator pushes and for validation flows where there is no controller (or for nodes still in `installer-only` mode that haven't been registered as DiskLayout targets). diff --git a/docs/storage.md b/docs/storage.md index 0931c9f..43c8aec 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -153,17 +153,47 @@ There are two equivalent ways to drive a day-2 disk change. Submit a `DiskLayout` manifest to the controller; the controller persists it, replicates it, and the controller-side reconciler pushes it to the target -node: +node. + +**Preferred — structured YAML (`spec.diskLayout`):** `kctl` expands this to the +same `disko.devices` Nix the controller stores (see crate `kcore-disk-layout-yaml`). +You must set **exactly one** of `diskLayout`, `layoutNix`, or `layoutNixFile`. ```yaml kind: DiskLayout metadata: name: prod-data-pool spec: - nodeId: node-prod-01 - layoutNixFile: ./day2-disk.nix # or inline `layoutNix: |` + nodeId: kvm-node-192-168-40-105 + diskLayout: + disks: + - name: data1 + device: /dev/nvme1n1 + gpt: + partitions: + - name: kcore0 + size: "100%" + content: + type: filesystem + format: ext4 + mountpoint: /var/lib/kcore/volumes1 +``` + +For LVM PV or ZFS-member partitions, use `content: { type: lvm_pv, vg: vg_kcore }` +or `content: { type: zfs, pool: tank0 }`, and list empty stubs as needed: + +```yaml + diskLayout: + lvmVolumeGroups: + - name: vg_kcore + zfsPools: + - name: tank0 + disks: [ ... ] ``` +**Advanced — raw Nix:** inline `layoutNix: |` or `layoutNixFile: ./day2-disk.nix` +when you need disko features not covered by the YAML schema yet. + ```bash kcore-kctl diff -f day2-disk-layout.yaml # controller pre-flight, no writes kcore-kctl apply -f day2-disk-layout.yaml # creates/updates DiskLayout diff --git a/docs/user/README.md b/docs/user/README.md index 2abce43..de79a75 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -7,6 +7,7 @@ Operator-focused guides are published on the product site: | Guide | Link | |--------|------| | Overview | [docs/user/index.html](https://kcorehypervisor.com/docs/user/index.html) | +| Appliance console | [appliance-console.html](https://kcorehypervisor.com/docs/user/appliance-console.html) | | Add a node to the cluster | [add-node.html](https://kcorehypervisor.com/docs/user/add-node.html) | | VM creation modes | [vm-creation.html](https://kcorehypervisor.com/docs/user/vm-creation.html) | | VM images workflow | [images.html](https://kcorehypervisor.com/docs/user/images.html) | diff --git a/docs/user/appliance-console.md b/docs/user/appliance-console.md new file mode 100644 index 0000000..3b948fb --- /dev/null +++ b/docs/user/appliance-console.md @@ -0,0 +1,86 @@ +# Appliance console + +kcore nodes display a local appliance console on the host screen instead of a +standard Linux login prompt. The console is read-only and is designed for quick +status checks during install, boot, and onsite troubleshooting. + +Use remote management for administration: + +```bash +kcorectl login https://:8443 +``` + +Local shell login is disabled by design. + +## What you see + +The console opens to the **Overview** page and shows: + +- Product name: **kcore hypervisor** +- Hostname, kcore version, and build ID +- Management URL and local API endpoint +- Cluster name and node role when known +- Overall health, uptime, and current local time +- Local login status (`disabled`) +- Remote management hint using `kcorectl` + +The console keeps refreshing in the background. If the local API is not ready, +the screen still opens and shows `API: unavailable`. + +## Pages + +| Page | Shows | +| --- | --- | +| Overview | Node identity, version, management URL, health, uptime, and remote-management command | +| Network | NIC table with interface, MAC, operational state, IPv4, IPv6, MTU, speed, driver, default-route marker, and management marker | +| Storage | Disk table with device, path, model, serial, human-readable size, SSD/HDD/NVMe type, read-only flag, mountpoints, health, and usage role | +| Diagnostics | Local kcore service status for node-agent, controller, and dashboard | +| Help | Keyboard shortcuts and security reminders | + +Missing values are shown as `—`. + +## Keyboard shortcuts + +| Key | Action | +| --- | --- | +| `Tab`, `Right` | Next page | +| `Shift+Tab`, `Left` | Previous page | +| `1` to `5` | Jump to Overview, Network, Storage, Diagnostics, or Help | +| `Up`, `Down` | Move selection in the current table | +| `r` | Refresh inventory now | +| `h` or `?` | Open Help | +| `Esc` | Return to Overview | +| `q` | Disabled in production | +| `Ctrl+C` | Disabled in production | + +## Security model + +The appliance console is intentionally not a privileged shell. + +- No local username/password prompt is exposed. +- Reboot and shutdown actions are not available from the local TUI. +- Real administration happens through the authenticated remote API and + `kcorectl`. +- If the console process exits or crashes, `systemd` restarts it. + +For production hosts, also harden the boot path: + +- Protect UEFI/firmware setup with a password. +- Protect GRUB and disable unauthenticated kernel command-line editing. +- Use Secure Boot where supported. +- Disable unauthenticated recovery shells. +- Keep management interfaces on trusted networks. + +## Troubleshooting + +If the console still shows a Linux login prompt: + +1. Confirm `kcore-console.service` is enabled and running. +2. Confirm `getty@tty1.service` and `autovt@.service` are masked or disabled. +3. Confirm `getty@tty2.service` through `getty@tty6.service` are also disabled, + because users can switch virtual terminals with `Ctrl+Alt+F2` to `F6`. +4. Reboot and check the screen attached to `tty1`. + +If network or disk tables are incomplete, the node may be missing Linux data +from `ip`, `/sys`, or `lsblk`; the console will keep rendering and refresh +again automatically. diff --git a/flake.nix b/flake.nix index 25747dc..3e59bab 100644 --- a/flake.nix +++ b/flake.nix @@ -52,7 +52,8 @@ path: type: (craneLib.filterCargoSources path type) || pkgs.lib.hasPrefix "${toString ./.}/proto/" (toString path) - || pkgs.lib.hasPrefix "${toString ./.}/crates/dashboard/assets/" (toString path); + || pkgs.lib.hasPrefix "${toString ./.}/crates/dashboard/assets/" (toString path) + || pkgs.lib.hasPrefix "${toString ./.}/crates/kcore-console/" (toString path); }; commonArgs = { @@ -104,6 +105,18 @@ cargoExtraArgs = "-p kcore-dashboard"; } ); + + kcoreGitRev = inputs.self.shortRev or inputs.self.dirtyShortRev or "dev"; + + kcore-console = craneLib.buildPackage ( + commonArgs + // { + inherit cargoArtifacts; + pname = "kcore-console"; + cargoExtraArgs = "-p kcore-console"; + KCORE_GIT_REV = kcoreGitRev; + } + ); in { packages = { @@ -113,6 +126,7 @@ kcore-controller kcore-kctl kcore-dashboard + kcore-console ; }; @@ -122,6 +136,7 @@ kcore-controller kcore-kctl kcore-dashboard + kcore-console ; clippy = craneLib.cargoClippy ( commonArgs @@ -209,6 +224,7 @@ controller = inputs.self.packages.x86_64-linux.kcore-controller; kctl = inputs.self.packages.x86_64-linux.kcore-kctl; dashboard = inputs.self.packages.x86_64-linux.kcore-dashboard; + kcoreConsole = inputs.self.packages.x86_64-linux.kcore-console; diskoPackage = inputs.disko.packages.x86_64-linux.default; kcoreDiskoModule = ./modules/kcore-disko.nix; kcoreBrandingModule = ./modules/kcore-branding.nix; @@ -216,6 +232,11 @@ { system.stateVersion = "25.05"; nixpkgs.config.allowUnfree = true; + nixpkgs.overlays = [ + (_final: _prev: { + kcore-console = kcoreConsole; + }) + ]; boot.loader.timeout = lib.mkForce 0; boot.loader.systemd-boot.editor = false; @@ -301,6 +322,7 @@ pkgs.cryptsetup pkgs.tpm2-tools pkgs.openssl + kcoreConsole nodeAgent controller kctl @@ -432,14 +454,130 @@ echo "" print_install_overview() { - echo "kcoreOS hardware discovery" - echo "--------------------------" - echo "Network interfaces:" - ip -o -4 addr show scope global | awk '{print " - " $2 " " $4}' || true - echo "" - echo "Disks:" - lsblk -d -o NAME,SIZE,MODEL,TYPE | awk '$4 == "disk" {printf " - /dev/%s %s %s\n", $1, $2, $3}' - echo "" + ESC="$(printf '\033')" + RESET="$ESC[0m" + HEADER_BG1="$ESC[48;2;99;102;241m" + HEADER_BG2="$ESC[48;2;123;95;246m" + PANEL_BG="$ESC[48;2;13;20;45m" + SEPARATOR="$ESC[38;2;99;102;241m" + TEXT="$ESC[38;2;235;236;255m" + WARN="$ESC[38;2;255;190;130m" + cols="$(tput cols 2>/dev/null || echo 80)" + [ "$cols" -lt 80 ] && cols=80 + left_w=$(( (cols - 3) / 2 )) + right_w=$(( cols - left_w - 3 )) + + fit() { + text="$1" + width="$2" + printf "%-''${width}.''${width}s" "$text" + } + + print_band() { + color="$1" + msg="$2" + printf "%b" "$color" + printf "%-''${cols}.''${cols}s" " $msg " + printf "%b\n" "$RESET" + } + + get_primary_ip() { + ip -o -4 route show default 2>/dev/null | awk 'NR==1 {print $9}' + } + + service_status() { + svc="$1" + port="$2" + if systemctl is-active --quiet "$svc"; then + ip_addr="$(get_primary_ip)" + [ -z "$ip_addr" ] && ip_addr="-" + printf "%s:%s" "$ip_addr" "$port" + else + printf "not installed" + fi + } + + left_file="$(mktemp)" + right_file="$(mktemp)" + cleanup_overview() { rm -f "$left_file" "$right_file"; } + trap cleanup_overview RETURN + + { + printf "Network Interfaces\n" + printf "%-12s %-8s %-17s %s\n" "NAME" "STATE" "MAC" "PRIMARY IPv4" + iface_count=0 + while read -r iface; do + [ -z "$iface" ] && continue + [ "$iface" = "lo" ] && continue + state="$(cat "/sys/class/net/$iface/operstate" 2>/dev/null || echo "unknown")" + mac="$(cat "/sys/class/net/$iface/address" 2>/dev/null || echo "-")" + addr="$(ip -o -4 addr show dev "$iface" scope global 2>/dev/null | awk 'NR==1 {print $4}')" + [ -z "$addr" ] && addr="-" + printf "%-12s %-8s %-17s %s\n" "$iface" "$state" "$mac" "$addr" + iface_count=$((iface_count + 1)) + done < <(ls /sys/class/net 2>/dev/null | sort) + if [ "$iface_count" -eq 0 ]; then + printf "(no network interfaces detected)\n" + fi + printf "\nDisks\n" + printf "%-14s %-8s %-12s %s\n" "PATH" "SIZE" "LABEL/UUID" "MODEL" + disk_count=0 + while read -r row; do + path="$(printf "%s\n" "$row" | sed -n 's/.*PATH=\"\([^\"]*\)\".*/\1/p')" + size="$(printf "%s\n" "$row" | sed -n 's/.*SIZE=\"\([^\"]*\)\".*/\1/p')" + label="$(printf "%s\n" "$row" | sed -n 's/.*LABEL=\"\([^\"]*\)\".*/\1/p')" + uuid="$(printf "%s\n" "$row" | sed -n 's/.*UUID=\"\([^\"]*\)\".*/\1/p')" + model="$(printf "%s\n" "$row" | sed -n 's/.*MODEL=\"\([^\"]*\)\".*/\1/p')" + dtype="$(printf "%s\n" "$row" | sed -n 's/.*TYPE=\"\([^\"]*\)\".*/\1/p')" + [ "$dtype" != "disk" ] && continue + label_uuid="$label" + [ -z "$label_uuid" ] && label_uuid="$uuid" + [ -z "$label_uuid" ] && label_uuid="-" + [ -z "$model" ] && model="-" + printf "%-14s %-8s %-12s %s\n" "$path" "$size" "$label_uuid" "$model" + disk_count=$((disk_count + 1)) + done < <(lsblk -dn -P -o PATH,SIZE,LABEL,UUID,MODEL,TYPE 2>/dev/null || true) + if [ "$disk_count" -eq 0 ]; then + printf "(no disks detected)\n" + fi + } > "$left_file" + + { + printf "Services\n" + printf "%-14s %s\n" "NAME" "ENDPOINT / STATUS" + printf "%-14s %s\n" "node-agent" "$(service_status kcore-node-agent.service 9091)" + printf "%-14s %s\n" "controller" "$(service_status kcore-controller.service 9090)" + printf "%-14s %s\n" "dashboard" "$(service_status kcore-dashboard.service 8080)" + printf "\nAccess\n" + printf "%s\n" "SSH only (no local login)" + printf "%s\n" "Use kctl from operator host" + } > "$right_file" + + mapfile -t left_rows < "$left_file" + mapfile -t right_rows < "$right_file" + max_rows="''${#left_rows[@]}" + if [ "''${#right_rows[@]}" -gt "$max_rows" ]; then + max_rows="''${#right_rows[@]}" + fi + + print_band "$HEADER_BG1$TEXT" "kcoreOS Installer" + print_band "$HEADER_BG1$TEXT" "Declarative Virtualization Platform" + print_band "$HEADER_BG2$TEXT" "for Edge & Datacenters" + printf "\n" + for ((i=0; i&2 - rm -f /etc/issue - install -m 0644 /etc/issue.kcore-static /etc/issue - fi - ''; - }; - - systemd.timers.kcore-issue-refresh = { - description = "Periodic kcoreOS issue refresh"; - wantedBy = [ "timers.target" ]; - timerConfig = { - OnBootSec = "20s"; - OnUnitActiveSec = "5min"; - Unit = "kcore-issue-refresh.service"; + Type = "simple"; + Restart = "always"; + RestartSec = "2s"; + ExecStart = "${kcoreConsoleExe} --tty /dev/tty1"; + User = "root"; + Group = "root"; + # ip, lsblk, systemctl for inventory + Environment = "PATH=${ + lib.makeBinPath ( + with pkgs; + [ + coreutils + iproute2 + util-linux + systemd + ] + ) + }:/run/wrappers/bin"; + UMask = "0077"; + NoNewPrivileges = true; + ProtectSystem = true; + ProtectHome = true; + PrivateTmp = true; + StandardInput = "tty"; + StandardOutput = "tty"; + StandardError = "journal"; + TTYPath = "/dev/tty1"; + TTYReset = true; + TTYVHangup = true; + TTYVTDisallocate = true; }; + unitConfig.Conflicts = [ + "getty@tty1.service" + "autovt@tty1.service" + ]; }; } diff --git a/packaging/systemd/kcore-console.service b/packaging/systemd/kcore-console.service new file mode 100644 index 0000000..3ab4c82 --- /dev/null +++ b/packaging/systemd/kcore-console.service @@ -0,0 +1,40 @@ +# Reference unit for non-NixOS or custom packaging. On NixOS, use +# `modules/kcore-branding.nix` (or copy the generated unit from a built system). +# +# The control plane user `kcore-console` cannot read+write /dev/tty1 on most +# Linux systems (root owns the TTY, group `tty` is often write-only). The +# unit below runs as root for a reliable full-screen TUI. Tighten with a +# dedicated udev/ACL policy if you must drop privileges. +# +# Mask virtual terminals so no agetty/login is shown (see docs/appliance-console.md). +# +[Unit] +Description=kcore Hypervisor Appliance Console +Documentation=https://kcore.ai/docs +After=network-online.target local-fs.target +Wants=network-online.target +Conflicts=getty@tty1.service + +[Service] +Type=simple +Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/usr/bin/kcore-console --tty /dev/tty1 +Restart=always +RestartSec=2 +StandardInput=tty +StandardOutput=tty +StandardError=journal +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +TTYVTDisallocate=yes +User=root +Group=root +UMask=0077 +NoNewPrivileges=yes +ProtectSystem=yes +ProtectHome=yes +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target