Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 156d707377 | |||
| dc1a2e3a0f | |||
| 5d6b8e6253 | |||
| 0cba083305 | |||
| a6be7a4788 | |||
| 2384f7f9b9 | |||
| cd5ef65d3d | |||
| 7bf9ca6201 | |||
| f587b42797 | |||
| 7ae464e172 | |||
| 980c9a20a2 | |||
| 448a38dede | |||
| f12e20b0f3 | |||
| 564d1f37e7 | |||
| 65bfb9f617 | |||
| 4f4ef6259b | |||
| 505263cec6 | |||
| 61dd686fb9 | |||
| c0f7a97a6f | |||
| 9575077045 | |||
| 34a1f7b9dc | |||
| d11aa11f99 | |||
| 0ca06d2507 | |||
| 6693f3a05f | |||
| de252d27b9 |
@@ -49,8 +49,12 @@ hostname2 = [
|
||||
### Navigation
|
||||
- **Tab**: Switch between hosts
|
||||
- **↑↓ or j/k**: Select services
|
||||
- **s**: Start selected service (UserStart)
|
||||
- **S**: Stop selected service (UserStop)
|
||||
- **J**: Show service logs (journalctl)
|
||||
- **L**: Show custom log files
|
||||
- **R**: Rebuild current host
|
||||
- **B**: Run backup on current host
|
||||
- **q**: Quit dashboard
|
||||
|
||||
## Core Architecture Principles
|
||||
|
||||
230
Cargo.lock
generated
230
Cargo.lock
generated
@@ -17,9 +17,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -71,22 +71,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.10"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -95,6 +95,15 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -144,9 +153,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
@@ -156,9 +165,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.41"
|
||||
version = "1.2.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7"
|
||||
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -230,9 +239,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.49"
|
||||
version = "4.5.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f"
|
||||
checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -240,9 +249,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.49"
|
||||
version = "4.5.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730"
|
||||
checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -270,7 +279,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.72"
|
||||
version = "0.1.97"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -292,7 +301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.72"
|
||||
version = "0.1.97"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -315,7 +324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.72"
|
||||
version = "0.1.97"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
@@ -503,9 +512,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
|
||||
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
@@ -768,9 +777,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
|
||||
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -781,9 +790,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
|
||||
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
@@ -794,11 +803,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
|
||||
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
@@ -809,42 +817,38 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
|
||||
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
|
||||
checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
|
||||
checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.0.0"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
|
||||
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"stable_deref_trait",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -885,9 +889,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.6"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
@@ -897,9 +904,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
@@ -928,9 +935,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.81"
|
||||
version = "0.3.82"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
|
||||
checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -988,9 +995,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
@@ -1104,6 +1111,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.32.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
@@ -1112,15 +1128,15 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.1"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.74"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
@@ -1150,9 +1166,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.110"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -1262,36 +1278,37 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a"
|
||||
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.101"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
|
||||
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.27"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e66fcd288453b748497d8fb18bccc83a16b0518e3906d4b8df0a8d42d93dbb1c"
|
||||
checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.41"
|
||||
version = "1.0.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -1611,9 +1628,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio 0.8.11",
|
||||
@@ -1716,9 +1733,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.107"
|
||||
version = "2.0.110"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b"
|
||||
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1826,9 +1843,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
|
||||
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
@@ -1874,9 +1891,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.16"
|
||||
version = "0.7.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
|
||||
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -2001,9 +2018,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.19"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
@@ -2055,9 +2072,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
@@ -2107,9 +2124,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.104"
|
||||
version = "0.2.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
|
||||
checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -2118,25 +2135,11 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.54"
|
||||
version = "0.4.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
|
||||
checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
@@ -2147,9 +2150,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.104"
|
||||
version = "0.2.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
|
||||
checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -2157,31 +2160,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.104"
|
||||
version = "0.2.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
|
||||
checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.104"
|
||||
version = "0.2.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
|
||||
checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.81"
|
||||
version = "0.3.82"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
|
||||
checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -2535,17 +2538,16 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
|
||||
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
@@ -2553,9 +2555,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
|
||||
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2616,9 +2618,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
|
||||
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
@@ -2627,9 +2629,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.4"
|
||||
version = "0.11.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b"
|
||||
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2638,9 +2640,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
|
||||
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -88,7 +88,9 @@ cm-dashboard • ● cmbox ● srv01 ● srv02 ● steambox
|
||||
- **s**: Start selected service (UserStart)
|
||||
- **S**: Stop selected service (UserStop)
|
||||
- **J**: Show service logs (journalctl in tmux popup)
|
||||
- **L**: Show custom log files (tail -f custom paths in tmux popup)
|
||||
- **R**: Rebuild current host
|
||||
- **B**: Run backup on current host
|
||||
- **q**: Quit
|
||||
|
||||
### Status Indicators
|
||||
@@ -173,9 +175,10 @@ subscriber_ports = [6130]
|
||||
[hosts]
|
||||
predefined_hosts = ["cmbox", "srv01", "srv02"]
|
||||
|
||||
[ui]
|
||||
ssh_user = "cm"
|
||||
[ssh]
|
||||
rebuild_user = "cm"
|
||||
rebuild_alias = "nixos-rebuild-cmtec"
|
||||
backup_alias = "cm-backup-run"
|
||||
```
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-agent"
|
||||
version = "0.1.73"
|
||||
version = "0.1.98"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
||||
use tokio::time::interval;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use crate::communication::{AgentCommand, ServiceAction, ZmqHandler};
|
||||
use crate::communication::{AgentCommand, ZmqHandler};
|
||||
use crate::config::AgentConfig;
|
||||
use crate::metrics::MetricCollectionManager;
|
||||
use crate::notifications::NotificationManager;
|
||||
@@ -315,79 +315,10 @@ impl Agent {
|
||||
info!("Processing Ping command - agent is alive");
|
||||
// Could send a response back via ZMQ if needed
|
||||
}
|
||||
AgentCommand::ServiceControl { service_name, action } => {
|
||||
info!("Processing ServiceControl command: {} {:?}", service_name, action);
|
||||
if let Err(e) = self.handle_service_control(&service_name, &action).await {
|
||||
error!("Failed to execute service control: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle systemd service control commands
|
||||
async fn handle_service_control(&mut self, service_name: &str, action: &ServiceAction) -> Result<()> {
|
||||
let (action_str, is_user_action) = match action {
|
||||
ServiceAction::Start => ("start", false),
|
||||
ServiceAction::Stop => ("stop", false),
|
||||
ServiceAction::Status => ("status", false),
|
||||
ServiceAction::UserStart => ("start", true),
|
||||
ServiceAction::UserStop => ("stop", true),
|
||||
};
|
||||
|
||||
info!("Executing systemctl {} {} (user action: {})", action_str, service_name, is_user_action);
|
||||
|
||||
// Handle user-stopped service tracking before systemctl execution (stop only)
|
||||
match action {
|
||||
ServiceAction::UserStop => {
|
||||
info!("Marking service '{}' as user-stopped", service_name);
|
||||
if let Err(e) = self.service_tracker.mark_user_stopped(service_name) {
|
||||
error!("Failed to mark service as user-stopped: {}", e);
|
||||
} else {
|
||||
// Sync to global tracker
|
||||
UserStoppedServiceTracker::update_global(&self.service_tracker);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Spawn the systemctl command asynchronously to avoid blocking the agent
|
||||
let service_name_clone = service_name.to_string();
|
||||
let action_str_clone = action_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::process::Command::new("sudo")
|
||||
.arg("systemctl")
|
||||
.arg(&action_str_clone)
|
||||
.arg(format!("{}.service", service_name_clone))
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
info!("Service {} {} completed successfully", service_name_clone, action_str_clone);
|
||||
if !output.stdout.is_empty() {
|
||||
debug!("stdout: {}", String::from_utf8_lossy(&output.stdout));
|
||||
}
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
error!("Service {} {} failed: {}", service_name_clone, action_str_clone, stderr);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to execute systemctl {} {}: {}", action_str_clone, service_name_clone, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
info!("Service {} {} command initiated (non-blocking)", service_name, action_str);
|
||||
|
||||
// Note: Service status will be updated by the normal metric collection cycle
|
||||
// once the systemctl operation completes
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check metrics for user-stopped services that are now active and clear their flags
|
||||
fn clear_user_stopped_flags_for_active_services(&mut self, metrics: &[Metric]) {
|
||||
|
||||
@@ -25,6 +25,25 @@ impl BackupCollector {
|
||||
}
|
||||
|
||||
async fn read_backup_status(&self) -> Result<Option<BackupStatusToml>, CollectorError> {
|
||||
// Check if we're in maintenance mode
|
||||
if std::fs::metadata("/tmp/cm-maintenance").is_ok() {
|
||||
// Return special maintenance mode status
|
||||
let maintenance_status = BackupStatusToml {
|
||||
backup_name: "maintenance".to_string(),
|
||||
start_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
current_time: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
duration_seconds: 0,
|
||||
status: "pending".to_string(),
|
||||
last_updated: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
|
||||
disk_space: None,
|
||||
disk_product_name: None,
|
||||
disk_serial_number: None,
|
||||
disk_wear_percent: None,
|
||||
services: HashMap::new(),
|
||||
};
|
||||
return Ok(Some(maintenance_status));
|
||||
}
|
||||
|
||||
// Check if backup status file exists
|
||||
if !std::path::Path::new(&self.backup_status_file).exists() {
|
||||
return Ok(None); // File doesn't exist, but this is not an error
|
||||
@@ -79,7 +98,9 @@ impl BackupCollector {
|
||||
}
|
||||
}
|
||||
"failed" => Status::Critical,
|
||||
"warning" => Status::Warning, // Backup completed with warnings
|
||||
"running" => Status::Ok, // Currently running is OK
|
||||
"pending" => Status::Pending, // Maintenance mode or backup starting
|
||||
_ => Status::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -136,6 +157,7 @@ impl Collector for BackupCollector {
|
||||
name: "backup_overall_status".to_string(),
|
||||
value: MetricValue::String(match overall_status {
|
||||
Status::Ok => "ok".to_string(),
|
||||
Status::Inactive => "inactive".to_string(),
|
||||
Status::Pending => "pending".to_string(),
|
||||
Status::Warning => "warning".to_string(),
|
||||
Status::Critical => "critical".to_string(),
|
||||
@@ -199,6 +221,7 @@ impl Collector for BackupCollector {
|
||||
name: format!("backup_service_{}_status", service_name),
|
||||
value: MetricValue::String(match service_status {
|
||||
Status::Ok => "ok".to_string(),
|
||||
Status::Inactive => "inactive".to_string(),
|
||||
Status::Pending => "pending".to_string(),
|
||||
Status::Warning => "warning".to_string(),
|
||||
Status::Critical => "critical".to_string(),
|
||||
@@ -377,6 +400,25 @@ impl Collector for BackupCollector {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(wear_percent) = backup_status.disk_wear_percent {
|
||||
let wear_status = if wear_percent >= 90.0 {
|
||||
Status::Critical
|
||||
} else if wear_percent >= 75.0 {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
};
|
||||
|
||||
metrics.push(Metric {
|
||||
name: "backup_disk_wear_percent".to_string(),
|
||||
value: MetricValue::Float(wear_percent),
|
||||
status: wear_status,
|
||||
timestamp,
|
||||
description: Some("Backup disk wear percentage from SMART data".to_string()),
|
||||
unit: Some("percent".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Count services by status
|
||||
let mut status_counts = HashMap::new();
|
||||
for service in backup_status.services.values() {
|
||||
@@ -410,6 +452,7 @@ pub struct BackupStatusToml {
|
||||
pub disk_space: Option<DiskSpace>,
|
||||
pub disk_product_name: Option<String>,
|
||||
pub disk_serial_number: Option<String>,
|
||||
pub disk_wear_percent: Option<f32>,
|
||||
pub services: HashMap<String, ServiceStatus>,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ use tracing::debug;
|
||||
|
||||
use super::{Collector, CollectorError};
|
||||
use crate::config::SystemdConfig;
|
||||
use crate::service_tracker::UserStoppedServiceTracker;
|
||||
|
||||
/// Systemd collector for monitoring systemd services
|
||||
pub struct SystemdCollector {
|
||||
@@ -357,33 +356,15 @@ impl SystemdCollector {
|
||||
/// Calculate service status, taking user-stopped services into account
|
||||
fn calculate_service_status(&self, service_name: &str, active_status: &str) -> Status {
|
||||
match active_status.to_lowercase().as_str() {
|
||||
"active" => {
|
||||
// If service is now active and was marked as user-stopped, clear the flag
|
||||
if UserStoppedServiceTracker::is_service_user_stopped(service_name) {
|
||||
debug!("Service '{}' is now active - clearing user-stopped flag", service_name);
|
||||
// Note: We can't directly clear here because this is a read-only context
|
||||
// The agent will need to handle this differently
|
||||
}
|
||||
Status::Ok
|
||||
},
|
||||
"active" => Status::Ok,
|
||||
"inactive" | "dead" => {
|
||||
// Check if this service was stopped by user action
|
||||
if UserStoppedServiceTracker::is_service_user_stopped(service_name) {
|
||||
debug!("Service '{}' is inactive but marked as user-stopped - treating as OK", service_name);
|
||||
Status::Ok
|
||||
} else {
|
||||
Status::Warning
|
||||
}
|
||||
debug!("Service '{}' is inactive - treating as Inactive status", service_name);
|
||||
Status::Inactive
|
||||
},
|
||||
"failed" | "error" => Status::Critical,
|
||||
"activating" | "deactivating" | "reloading" | "start" | "stop" | "restart" => {
|
||||
// For user-stopped services that are transitioning, keep them as OK during transition
|
||||
if UserStoppedServiceTracker::is_service_user_stopped(service_name) {
|
||||
debug!("Service '{}' is transitioning but was user-stopped - treating as OK", service_name);
|
||||
Status::Ok
|
||||
} else {
|
||||
Status::Pending
|
||||
}
|
||||
debug!("Service '{}' is transitioning - treating as Pending", service_name);
|
||||
Status::Pending
|
||||
},
|
||||
_ => Status::Unknown,
|
||||
}
|
||||
|
||||
@@ -98,19 +98,4 @@ pub enum AgentCommand {
|
||||
ToggleCollector { name: String, enabled: bool },
|
||||
/// Request status/health check
|
||||
Ping,
|
||||
/// Control systemd service
|
||||
ServiceControl {
|
||||
service_name: String,
|
||||
action: ServiceAction,
|
||||
},
|
||||
}
|
||||
|
||||
/// Service control actions
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub enum ServiceAction {
|
||||
Start,
|
||||
Stop,
|
||||
Status,
|
||||
UserStart, // User-initiated start (clears user-stopped flag)
|
||||
UserStop, // User-initiated stop (marks as user-stopped)
|
||||
}
|
||||
|
||||
@@ -90,14 +90,6 @@ impl UserStoppedServiceTracker {
|
||||
tracker
|
||||
}
|
||||
|
||||
/// Mark a service as user-stopped
|
||||
pub fn mark_user_stopped(&mut self, service_name: &str) -> Result<()> {
|
||||
info!("Marking service '{}' as user-stopped", service_name);
|
||||
self.user_stopped_services.insert(service_name.to_string());
|
||||
self.save_to_storage()?;
|
||||
debug!("Service '{}' marked as user-stopped and saved to storage", service_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear user-stopped flag for a service (when user starts it)
|
||||
pub fn clear_user_stopped(&mut self, service_name: &str) -> Result<()> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard"
|
||||
version = "0.1.73"
|
||||
version = "0.1.98"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -9,14 +9,13 @@ use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::communication::{AgentCommand, ServiceAction, ZmqCommandSender, ZmqConsumer};
|
||||
use crate::communication::{ZmqConsumer};
|
||||
use crate::config::DashboardConfig;
|
||||
use crate::metrics::MetricStore;
|
||||
use crate::ui::{TuiApp, UiCommand};
|
||||
use crate::ui::TuiApp;
|
||||
|
||||
pub struct Dashboard {
|
||||
zmq_consumer: ZmqConsumer,
|
||||
zmq_command_sender: ZmqCommandSender,
|
||||
metric_store: MetricStore,
|
||||
tui_app: Option<TuiApp>,
|
||||
terminal: Option<Terminal<CrosstermBackend<io::Stdout>>>,
|
||||
@@ -58,14 +57,6 @@ impl Dashboard {
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize ZMQ command sender
|
||||
let zmq_command_sender = match ZmqCommandSender::new(&config.zmq) {
|
||||
Ok(sender) => sender,
|
||||
Err(e) => {
|
||||
error!("Failed to initialize ZMQ command sender: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Try to connect to hosts but don't fail if none are available
|
||||
match zmq_consumer.connect_to_predefined_hosts(&config.hosts).await {
|
||||
@@ -124,7 +115,6 @@ impl Dashboard {
|
||||
|
||||
Ok(Self {
|
||||
zmq_consumer,
|
||||
zmq_command_sender,
|
||||
metric_store,
|
||||
tui_app,
|
||||
terminal,
|
||||
@@ -151,16 +141,10 @@ impl Dashboard {
|
||||
match event::read() {
|
||||
Ok(event) => {
|
||||
if let Some(ref mut tui_app) = self.tui_app {
|
||||
// Handle input and check for commands
|
||||
// Handle input
|
||||
match tui_app.handle_input(event) {
|
||||
Ok(Some(command)) => {
|
||||
// Execute the command
|
||||
if let Err(e) = self.execute_ui_command(command).await {
|
||||
error!("Failed to execute UI command: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No command, check if we should quit
|
||||
Ok(_) => {
|
||||
// Check if we should quit
|
||||
if tui_app.should_quit() {
|
||||
info!("Quit requested, exiting dashboard");
|
||||
break;
|
||||
@@ -287,33 +271,6 @@ impl Dashboard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a UI command by sending it to the appropriate agent
|
||||
async fn execute_ui_command(&self, command: UiCommand) -> Result<()> {
|
||||
match command {
|
||||
UiCommand::ServiceStart { hostname, service_name } => {
|
||||
info!("Sending user start command for service {} on {}", service_name, hostname);
|
||||
let agent_command = AgentCommand::ServiceControl {
|
||||
service_name: service_name.clone(),
|
||||
action: ServiceAction::UserStart,
|
||||
};
|
||||
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
|
||||
}
|
||||
UiCommand::ServiceStop { hostname, service_name } => {
|
||||
info!("Sending user stop command for service {} on {}", service_name, hostname);
|
||||
let agent_command = AgentCommand::ServiceControl {
|
||||
service_name: service_name.clone(),
|
||||
action: ServiceAction::UserStop,
|
||||
};
|
||||
self.zmq_command_sender.send_command(&hostname, agent_command).await?;
|
||||
}
|
||||
UiCommand::TriggerBackup { hostname } => {
|
||||
info!("Trigger backup requested for {}", hostname);
|
||||
// TODO: Implement backup trigger command
|
||||
info!("Backup trigger not yet implemented");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -5,40 +5,6 @@ use zmq::{Context, Socket, SocketType};
|
||||
|
||||
use crate::config::ZmqConfig;
|
||||
|
||||
/// Commands that can be sent to agents
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub enum AgentCommand {
|
||||
/// Request immediate metric collection
|
||||
CollectNow,
|
||||
/// Change collection interval
|
||||
SetInterval { seconds: u64 },
|
||||
/// Enable/disable a collector
|
||||
ToggleCollector { name: String, enabled: bool },
|
||||
/// Request status/health check
|
||||
Ping,
|
||||
/// Control systemd service
|
||||
ServiceControl {
|
||||
service_name: String,
|
||||
action: ServiceAction,
|
||||
},
|
||||
/// Rebuild NixOS system
|
||||
SystemRebuild {
|
||||
git_url: String,
|
||||
git_branch: String,
|
||||
working_dir: String,
|
||||
api_key_file: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Service control actions
|
||||
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||
pub enum ServiceAction {
|
||||
Start,
|
||||
Stop,
|
||||
Status,
|
||||
UserStart, // User-initiated start (clears user-stopped flag)
|
||||
UserStop, // User-initiated stop (marks as user-stopped)
|
||||
}
|
||||
|
||||
/// ZMQ consumer for receiving metrics from agents
|
||||
pub struct ZmqConsumer {
|
||||
@@ -202,42 +168,3 @@ impl ZmqConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
/// ZMQ command sender for sending commands to agents
|
||||
pub struct ZmqCommandSender {
|
||||
context: Context,
|
||||
}
|
||||
|
||||
impl ZmqCommandSender {
|
||||
pub fn new(_config: &ZmqConfig) -> Result<Self> {
|
||||
let context = Context::new();
|
||||
|
||||
info!("ZMQ command sender initialized");
|
||||
|
||||
Ok(Self { context })
|
||||
}
|
||||
|
||||
/// Send a command to a specific agent
|
||||
pub async fn send_command(&self, hostname: &str, command: AgentCommand) -> Result<()> {
|
||||
// Create a new PUSH socket for this command (ZMQ best practice)
|
||||
let socket = self.context.socket(SocketType::PUSH)?;
|
||||
|
||||
// Set socket options
|
||||
socket.set_linger(1000)?; // Wait up to 1 second on close
|
||||
socket.set_sndtimeo(5000)?; // 5 second send timeout
|
||||
|
||||
// Connect to agent's command port (6131)
|
||||
let address = format!("tcp://{}:6131", hostname);
|
||||
socket.connect(&address)?;
|
||||
|
||||
// Serialize command
|
||||
let serialized = serde_json::to_vec(&command)?;
|
||||
|
||||
// Send command
|
||||
socket.send(&serialized, 0)?;
|
||||
|
||||
info!("Sent command {:?} to agent at {}", command, hostname);
|
||||
|
||||
// Socket will be automatically closed when dropped
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,12 @@ pub struct SystemConfig {
|
||||
pub nixos_config_api_key_file: Option<String>,
|
||||
}
|
||||
|
||||
/// SSH configuration for rebuild operations
|
||||
/// SSH configuration for rebuild and backup operations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshConfig {
|
||||
pub rebuild_user: String,
|
||||
pub rebuild_alias: String,
|
||||
pub rebuild_cmd: String,
|
||||
pub service_manage_cmd: String,
|
||||
}
|
||||
|
||||
/// Service log file configuration per host
|
||||
|
||||
@@ -16,26 +16,12 @@ pub mod widgets;
|
||||
|
||||
use crate::config::DashboardConfig;
|
||||
use crate::metrics::MetricStore;
|
||||
use cm_dashboard_shared::{Metric, Status};
|
||||
use cm_dashboard_shared::Status;
|
||||
use theme::{Components, Layout as ThemeLayout, Theme, Typography};
|
||||
use widgets::{BackupWidget, ServicesWidget, SystemWidget, Widget};
|
||||
|
||||
/// Commands that can be triggered from the UI
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UiCommand {
|
||||
ServiceStart { hostname: String, service_name: String },
|
||||
ServiceStop { hostname: String, service_name: String },
|
||||
TriggerBackup { hostname: String },
|
||||
}
|
||||
|
||||
|
||||
/// Types of commands for status tracking
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CommandType {
|
||||
ServiceStart,
|
||||
ServiceStop,
|
||||
BackupTrigger,
|
||||
}
|
||||
|
||||
/// Panel types for focus management
|
||||
|
||||
@@ -48,14 +34,8 @@ pub struct HostWidgets {
|
||||
pub services_widget: ServicesWidget,
|
||||
/// Backup widget state
|
||||
pub backup_widget: BackupWidget,
|
||||
/// Scroll offsets for each panel
|
||||
pub system_scroll_offset: usize,
|
||||
pub services_scroll_offset: usize,
|
||||
pub backup_scroll_offset: usize,
|
||||
/// Last update time for this host
|
||||
pub last_update: Option<Instant>,
|
||||
/// Pending service transitions for immediate visual feedback
|
||||
pub pending_service_transitions: HashMap<String, (CommandType, String, Instant)>, // service_name -> (command_type, original_status, start_time)
|
||||
}
|
||||
|
||||
impl HostWidgets {
|
||||
@@ -64,11 +44,7 @@ impl HostWidgets {
|
||||
system_widget: SystemWidget::new(),
|
||||
services_widget: ServicesWidget::new(),
|
||||
backup_widget: BackupWidget::new(),
|
||||
system_scroll_offset: 0,
|
||||
services_scroll_offset: 0,
|
||||
backup_scroll_offset: 0,
|
||||
last_update: None,
|
||||
pending_service_transitions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,8 +137,6 @@ impl TuiApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Clear completed transitions first
|
||||
self.clear_completed_transitions(&hostname, &service_metrics);
|
||||
|
||||
// Now get host widgets and update them
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
@@ -198,14 +172,6 @@ impl TuiApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep hosts that have pending transitions even if they're offline
|
||||
for (hostname, host_widgets) in &self.host_widgets {
|
||||
if !host_widgets.pending_service_transitions.is_empty() {
|
||||
if !all_hosts.contains(hostname) {
|
||||
all_hosts.push(hostname.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_hosts.sort();
|
||||
self.available_hosts = all_hosts;
|
||||
@@ -236,7 +202,7 @@ impl TuiApp {
|
||||
}
|
||||
|
||||
/// Handle keyboard input
|
||||
pub fn handle_input(&mut self, event: Event) -> Result<Option<UiCommand>> {
|
||||
pub fn handle_input(&mut self, event: Event) -> Result<()> {
|
||||
if let Event::Key(key) = event {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
@@ -254,12 +220,12 @@ impl TuiApp {
|
||||
let connection_ip = self.get_connection_ip(&hostname);
|
||||
// Create command that shows logo, rebuilds, and waits for user input
|
||||
let logo_and_rebuild = format!(
|
||||
"bash -c 'cat << \"EOF\"\nNixOS System Rebuild\nTarget: {} ({})\n\nEOF\nssh -tt {}@{} \"bash -ic {}\"\necho\necho \"========================================\"\necho \"Rebuild completed. Press any key to close...\"\necho \"========================================\"\nread -n 1 -s\nexit'",
|
||||
"echo 'Rebuilding system: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
||||
hostname,
|
||||
connection_ip,
|
||||
self.config.ssh.rebuild_user,
|
||||
connection_ip,
|
||||
self.config.ssh.rebuild_alias
|
||||
self.config.ssh.rebuild_cmd
|
||||
);
|
||||
|
||||
std::process::Command::new("tmux")
|
||||
@@ -272,30 +238,41 @@ impl TuiApp {
|
||||
.ok(); // Ignore errors, tmux will handle them
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
// Service start command
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
if self.start_command(&hostname, CommandType::ServiceStart, service_name.clone()) {
|
||||
return Ok(Some(UiCommand::ServiceStart { hostname, service_name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
// Service stop command
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
if self.start_command(&hostname, CommandType::ServiceStop, service_name.clone()) {
|
||||
return Ok(Some(UiCommand::ServiceStop { hostname, service_name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('J') => {
|
||||
// Show service logs via journalctl in tmux split window
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
KeyCode::Char('B') => {
|
||||
// Backup command - works on any panel for current host
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
let connection_ip = self.get_connection_ip(&hostname);
|
||||
let journalctl_command = format!(
|
||||
"bash -c \"ssh -tt {}@{} 'sudo journalctl -u {}.service -f --no-pager -n 50'; exit\"",
|
||||
// Create command that shows logo, runs backup, and waits for user input
|
||||
let logo_and_backup = format!(
|
||||
"echo 'Running backup: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"",
|
||||
hostname,
|
||||
connection_ip,
|
||||
self.config.ssh.rebuild_user,
|
||||
connection_ip,
|
||||
format!("{} start borgbackup", self.config.ssh.service_manage_cmd)
|
||||
);
|
||||
|
||||
std::process::Command::new("tmux")
|
||||
.arg("split-window")
|
||||
.arg("-v")
|
||||
.arg("-p")
|
||||
.arg("30")
|
||||
.arg(&logo_and_backup)
|
||||
.spawn()
|
||||
.ok(); // Ignore errors, tmux will handle them
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
// Service start command via SSH with progress display
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
let connection_ip = self.get_connection_ip(&hostname);
|
||||
let service_start_command = format!(
|
||||
"echo 'Starting service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} start {}'\"",
|
||||
service_name,
|
||||
hostname,
|
||||
self.config.ssh.rebuild_user,
|
||||
connection_ip,
|
||||
self.config.ssh.service_manage_cmd,
|
||||
service_name
|
||||
);
|
||||
|
||||
@@ -304,42 +281,55 @@ impl TuiApp {
|
||||
.arg("-v")
|
||||
.arg("-p")
|
||||
.arg("30")
|
||||
.arg(&journalctl_command)
|
||||
.arg(&service_start_command)
|
||||
.spawn()
|
||||
.ok(); // Ignore errors, tmux will handle them
|
||||
}
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
// Service stop command via SSH with progress display
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
let connection_ip = self.get_connection_ip(&hostname);
|
||||
let service_stop_command = format!(
|
||||
"echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} stop {}'\"",
|
||||
service_name,
|
||||
hostname,
|
||||
self.config.ssh.rebuild_user,
|
||||
connection_ip,
|
||||
self.config.ssh.service_manage_cmd,
|
||||
service_name
|
||||
);
|
||||
|
||||
std::process::Command::new("tmux")
|
||||
.arg("split-window")
|
||||
.arg("-v")
|
||||
.arg("-p")
|
||||
.arg("30")
|
||||
.arg(&service_stop_command)
|
||||
.spawn()
|
||||
.ok(); // Ignore errors, tmux will handle them
|
||||
}
|
||||
}
|
||||
KeyCode::Char('L') => {
|
||||
// Show custom service log file in tmux split window
|
||||
// Show service logs via service-manage script in tmux split window
|
||||
if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) {
|
||||
// Check if this service has a custom log file configured
|
||||
if let Some(host_logs) = self.config.service_logs.get(&hostname) {
|
||||
if let Some(log_config) = host_logs.iter().find(|config| config.service_name == service_name) {
|
||||
let connection_ip = self.get_connection_ip(&hostname);
|
||||
let tail_command = format!(
|
||||
"bash -c \"ssh -tt {}@{} 'sudo tail -n 50 -f {}'; exit\"",
|
||||
self.config.ssh.rebuild_user,
|
||||
connection_ip,
|
||||
log_config.log_file_path
|
||||
);
|
||||
|
||||
std::process::Command::new("tmux")
|
||||
.arg("split-window")
|
||||
.arg("-v")
|
||||
.arg("-p")
|
||||
.arg("30")
|
||||
.arg(&tail_command)
|
||||
.spawn()
|
||||
.ok(); // Ignore errors, tmux will handle them
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
// Trigger backup
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
self.start_command(&hostname, CommandType::BackupTrigger, hostname.clone());
|
||||
return Ok(Some(UiCommand::TriggerBackup { hostname }));
|
||||
let connection_ip = self.get_connection_ip(&hostname);
|
||||
let logs_command = format!(
|
||||
"ssh -tt {}@{} '{} logs {}'",
|
||||
self.config.ssh.rebuild_user,
|
||||
connection_ip,
|
||||
self.config.ssh.service_manage_cmd,
|
||||
service_name
|
||||
);
|
||||
|
||||
std::process::Command::new("tmux")
|
||||
.arg("split-window")
|
||||
.arg("-v")
|
||||
.arg("-p")
|
||||
.arg("30")
|
||||
.arg(&logs_command)
|
||||
.spawn()
|
||||
.ok(); // Ignore errors, tmux will handle them
|
||||
}
|
||||
}
|
||||
KeyCode::Char('w') => {
|
||||
@@ -374,7 +364,8 @@ impl TuiApp {
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
let connection_ip = self.get_connection_ip(&hostname);
|
||||
let ssh_command = format!(
|
||||
"ssh -tt {}@{}",
|
||||
"echo 'Opening SSH terminal to: {}' && ssh -tt {}@{}",
|
||||
hostname,
|
||||
self.config.ssh.rebuild_user,
|
||||
connection_ip
|
||||
);
|
||||
@@ -414,7 +405,7 @@ impl TuiApp {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate between hosts
|
||||
@@ -468,86 +459,8 @@ impl TuiApp {
|
||||
self.should_quit
|
||||
}
|
||||
|
||||
/// Get current service status for state-aware command validation
|
||||
fn get_current_service_status(&self, hostname: &str, service_name: &str) -> Option<String> {
|
||||
if let Some(host_widgets) = self.host_widgets.get(hostname) {
|
||||
return host_widgets.services_widget.get_service_status(service_name);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Start command execution with immediate visual feedback
|
||||
pub fn start_command(&mut self, hostname: &str, command_type: CommandType, target: String) -> bool {
|
||||
// Get current service status to validate command
|
||||
let current_status = self.get_current_service_status(hostname, &target);
|
||||
|
||||
// Validate if command makes sense for current state
|
||||
let should_execute = match (&command_type, current_status.as_deref()) {
|
||||
(CommandType::ServiceStart, Some("inactive") | Some("failed") | Some("dead")) => true,
|
||||
(CommandType::ServiceStop, Some("active")) => true,
|
||||
(CommandType::ServiceStart, Some("active")) => {
|
||||
// Already running - don't execute
|
||||
false
|
||||
},
|
||||
(CommandType::ServiceStop, Some("inactive") | Some("failed") | Some("dead")) => {
|
||||
// Already stopped - don't execute
|
||||
false
|
||||
},
|
||||
(_, None) => {
|
||||
// Unknown service state - allow command to proceed
|
||||
true
|
||||
},
|
||||
_ => true, // Default: allow other combinations
|
||||
};
|
||||
|
||||
// ALWAYS store the pending transition for immediate visual feedback, even if we don't execute
|
||||
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
||||
host_widgets.pending_service_transitions.insert(
|
||||
target.clone(),
|
||||
(command_type, current_status.unwrap_or_else(|| "unknown".to_string()), Instant::now())
|
||||
);
|
||||
}
|
||||
|
||||
should_execute
|
||||
}
|
||||
|
||||
/// Clear pending transitions when real status updates arrive or timeout
|
||||
fn clear_completed_transitions(&mut self, hostname: &str, service_metrics: &[&Metric]) {
|
||||
if let Some(host_widgets) = self.host_widgets.get_mut(hostname) {
|
||||
let mut completed_services = Vec::new();
|
||||
|
||||
// Check each pending transition to see if real status has changed
|
||||
for (service_name, (command_type, original_status, _start_time)) in &host_widgets.pending_service_transitions {
|
||||
|
||||
// Look for status metric for this service
|
||||
for metric in service_metrics {
|
||||
if metric.name == format!("service_{}_status", service_name) {
|
||||
let new_status = metric.value.as_string();
|
||||
|
||||
// Check if status has changed from original (command completed)
|
||||
if &new_status != original_status {
|
||||
// Verify it changed in the expected direction
|
||||
let expected_change = match command_type {
|
||||
CommandType::ServiceStart => &new_status == "active",
|
||||
CommandType::ServiceStop => &new_status != "active",
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if expected_change {
|
||||
completed_services.push(service_name.clone());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove completed transitions
|
||||
for service_name in completed_services {
|
||||
host_widgets.pending_service_transitions.remove(&service_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -635,14 +548,10 @@ impl TuiApp {
|
||||
// Render services widget for current host
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
let is_focused = true; // Always show service selection
|
||||
let (scroll_offset, pending_transitions) = {
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
(host_widgets.services_scroll_offset, host_widgets.pending_service_transitions.clone())
|
||||
};
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets
|
||||
.services_widget
|
||||
.render_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, &pending_transitions); // Services takes full right side
|
||||
.render(frame, content_chunks[1], is_focused); // Services takes full right side
|
||||
}
|
||||
|
||||
// Render statusbar at the bottom
|
||||
@@ -680,12 +589,13 @@ impl TuiApp {
|
||||
// Split the title bar into left and right sections
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(15), Constraint::Min(0)])
|
||||
.constraints([Constraint::Length(22), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
// Left side: "cm-dashboard" text
|
||||
// Left side: "cm-dashboard" text with version
|
||||
let title_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION"));
|
||||
let left_span = Span::styled(
|
||||
" cm-dashboard",
|
||||
&title_text,
|
||||
Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD)
|
||||
);
|
||||
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
||||
@@ -757,34 +667,27 @@ impl TuiApp {
|
||||
return host_summary_metric.status;
|
||||
}
|
||||
|
||||
// Fallback to old aggregation logic with proper Pending handling
|
||||
// Rewritten status aggregation - only Critical, Warning, or OK for top bar
|
||||
let mut has_critical = false;
|
||||
let mut has_warning = false;
|
||||
let mut has_pending = false;
|
||||
let mut ok_count = 0;
|
||||
|
||||
for metric in &metrics {
|
||||
match metric.status {
|
||||
Status::Critical => has_critical = true,
|
||||
Status::Warning => has_warning = true,
|
||||
Status::Pending => has_pending = true,
|
||||
Status::Ok => ok_count += 1,
|
||||
Status::Unknown => {}, // Ignore unknown for aggregation
|
||||
Status::Offline => {}, // Ignore offline for aggregation
|
||||
// Treat all other statuses as OK for top bar aggregation
|
||||
Status::Ok | Status::Pending | Status::Inactive | Status::Unknown => {},
|
||||
Status::Offline => {}, // Ignore offline
|
||||
}
|
||||
}
|
||||
|
||||
// Priority order: Critical > Warning > Pending > Ok > Unknown
|
||||
// Only return Critical, Warning, or OK - no other statuses
|
||||
if has_critical {
|
||||
Status::Critical
|
||||
} else if has_warning {
|
||||
Status::Warning
|
||||
} else if has_pending {
|
||||
Status::Pending
|
||||
} else if ok_count > 0 {
|
||||
Status::Ok
|
||||
} else {
|
||||
Status::Unknown
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,9 +711,10 @@ impl TuiApp {
|
||||
shortcuts.push("Tab: Host".to_string());
|
||||
shortcuts.push("↑↓/jk: Select".to_string());
|
||||
shortcuts.push("r: Rebuild".to_string());
|
||||
shortcuts.push("B: Backup".to_string());
|
||||
shortcuts.push("s/S: Start/Stop".to_string());
|
||||
shortcuts.push("J: Logs".to_string());
|
||||
shortcuts.push("L: Custom".to_string());
|
||||
shortcuts.push("L: Logs".to_string());
|
||||
shortcuts.push("t: Terminal".to_string());
|
||||
shortcuts.push("w: Wake".to_string());
|
||||
|
||||
// Always show quit
|
||||
@@ -825,14 +729,10 @@ impl TuiApp {
|
||||
frame.render_widget(system_block, area);
|
||||
// Get current host widgets, create if none exist
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
let scroll_offset = {
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets.system_scroll_offset
|
||||
};
|
||||
// Clone the config to avoid borrowing issues
|
||||
let config = self.config.clone();
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets.system_widget.render_with_scroll(frame, inner_area, scroll_offset, &hostname, Some(&config));
|
||||
host_widgets.system_widget.render(frame, inner_area, &hostname, Some(&config));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,12 +743,8 @@ impl TuiApp {
|
||||
|
||||
// Get current host widgets for backup widget
|
||||
if let Some(hostname) = self.current_host.clone() {
|
||||
let scroll_offset = {
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets.backup_scroll_offset
|
||||
};
|
||||
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
||||
host_widgets.backup_widget.render_with_scroll(frame, inner_area, scroll_offset);
|
||||
host_widgets.backup_widget.render(frame, inner_area);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ impl Theme {
|
||||
pub fn status_color(status: Status) -> Color {
|
||||
match status {
|
||||
Status::Ok => Self::success(),
|
||||
Status::Inactive => Self::muted_text(), // Gray for inactive services in service list
|
||||
Status::Pending => Self::highlight(), // Blue for pending
|
||||
Status::Warning => Self::warning(),
|
||||
Status::Critical => Self::error(),
|
||||
@@ -243,6 +244,7 @@ impl StatusIcons {
|
||||
pub fn get_icon(status: Status) -> &'static str {
|
||||
match status {
|
||||
Status::Ok => "●",
|
||||
Status::Inactive => "○", // Empty circle for inactive services
|
||||
Status::Pending => "◉", // Hollow circle for pending
|
||||
Status::Warning => "◐",
|
||||
Status::Critical => "!",
|
||||
@@ -256,6 +258,7 @@ impl StatusIcons {
|
||||
let icon = Self::get_icon(status);
|
||||
let status_color = match status {
|
||||
Status::Ok => Theme::success(), // Green
|
||||
Status::Inactive => Theme::muted_text(), // Gray for inactive services
|
||||
Status::Pending => Theme::highlight(), // Blue
|
||||
Status::Warning => Theme::warning(), // Yellow
|
||||
Status::Critical => Theme::error(), // Red
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct BackupWidget {
|
||||
backup_disk_product_name: Option<String>,
|
||||
/// Backup disk serial number from SMART data
|
||||
backup_disk_serial_number: Option<String>,
|
||||
/// Backup disk wear percentage from SMART data
|
||||
backup_disk_wear_percent: Option<f32>,
|
||||
/// Backup disk filesystem label
|
||||
backup_disk_filesystem_label: Option<String>,
|
||||
/// Number of completed services
|
||||
@@ -65,6 +67,7 @@ impl BackupWidget {
|
||||
backup_disk_used_gb: None,
|
||||
backup_disk_product_name: None,
|
||||
backup_disk_serial_number: None,
|
||||
backup_disk_wear_percent: None,
|
||||
backup_disk_filesystem_label: None,
|
||||
services_completed_count: None,
|
||||
services_failed_count: None,
|
||||
@@ -197,6 +200,9 @@ impl Widget for BackupWidget {
|
||||
"backup_disk_serial_number" => {
|
||||
self.backup_disk_serial_number = Some(metric.value.as_string());
|
||||
}
|
||||
"backup_disk_wear_percent" => {
|
||||
self.backup_disk_wear_percent = metric.value.as_f32();
|
||||
}
|
||||
"backup_disk_filesystem_label" => {
|
||||
self.backup_disk_filesystem_label = Some(metric.value.as_string());
|
||||
}
|
||||
@@ -285,8 +291,8 @@ impl Widget for BackupWidget {
|
||||
}
|
||||
|
||||
impl BackupWidget {
|
||||
/// Render with scroll offset support
|
||||
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize) {
|
||||
/// Render backup widget
|
||||
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Latest backup section
|
||||
@@ -328,21 +334,31 @@ impl BackupWidget {
|
||||
);
|
||||
lines.push(ratatui::text::Line::from(disk_spans));
|
||||
|
||||
// Serial number as sub-item
|
||||
// Collect sub-items to determine tree structure
|
||||
let mut sub_items = Vec::new();
|
||||
|
||||
if let Some(serial) = &self.backup_disk_serial_number {
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(" ├─ ", Typography::tree()),
|
||||
ratatui::text::Span::styled(format!("S/N: {}", serial), Typography::secondary())
|
||||
]));
|
||||
sub_items.push(format!("S/N: {}", serial));
|
||||
}
|
||||
|
||||
// Usage as sub-item
|
||||
|
||||
if let Some(wear) = self.backup_disk_wear_percent {
|
||||
sub_items.push(format!("Wear: {:.0}%", wear));
|
||||
}
|
||||
|
||||
if let (Some(used), Some(total)) = (self.backup_disk_used_gb, self.backup_disk_total_gb) {
|
||||
let used_str = Self::format_size_with_proper_units(used);
|
||||
let total_str = Self::format_size_with_proper_units(total);
|
||||
sub_items.push(format!("Usage: {}/{}", used_str, total_str));
|
||||
}
|
||||
|
||||
// Render sub-items with proper tree structure
|
||||
let num_items = sub_items.len();
|
||||
for (i, item) in sub_items.into_iter().enumerate() {
|
||||
let is_last = i == num_items - 1;
|
||||
let tree_char = if is_last { " └─ " } else { " ├─ " };
|
||||
lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(" └─ ", Typography::tree()),
|
||||
ratatui::text::Span::styled(format!("Usage: {}/{}", used_str, total_str), Typography::secondary())
|
||||
ratatui::text::Span::styled(tree_char, Typography::tree()),
|
||||
ratatui::text::Span::styled(item, Typography::secondary())
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -366,42 +382,20 @@ impl BackupWidget {
|
||||
let total_lines = lines.len();
|
||||
let available_height = area.height as usize;
|
||||
|
||||
// Calculate scroll boundaries
|
||||
let max_scroll = if total_lines > available_height {
|
||||
total_lines - available_height
|
||||
} else {
|
||||
total_lines.saturating_sub(1)
|
||||
};
|
||||
let effective_scroll = scroll_offset.min(max_scroll);
|
||||
|
||||
// Apply scrolling if needed
|
||||
if scroll_offset > 0 || total_lines > available_height {
|
||||
// Show only what fits, with "X more below" if needed
|
||||
if total_lines > available_height {
|
||||
let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
|
||||
let mut visible_lines: Vec<_> = lines
|
||||
.into_iter()
|
||||
.skip(effective_scroll)
|
||||
.take(available_height)
|
||||
.take(lines_for_content)
|
||||
.collect();
|
||||
|
||||
// Add scroll indicator if there are hidden lines
|
||||
if total_lines > available_height {
|
||||
let hidden_above = effective_scroll;
|
||||
let hidden_below = total_lines.saturating_sub(effective_scroll + available_height);
|
||||
|
||||
if (hidden_above > 0 || hidden_below > 0) && !visible_lines.is_empty() {
|
||||
let scroll_text = if hidden_above > 0 && hidden_below > 0 {
|
||||
format!("... {} above, {} below", hidden_above, hidden_below)
|
||||
} else if hidden_above > 0 {
|
||||
format!("... {} more above", hidden_above)
|
||||
} else {
|
||||
format!("... {} more below", hidden_below)
|
||||
};
|
||||
|
||||
// Replace last line with scroll indicator
|
||||
visible_lines.pop();
|
||||
visible_lines.push(ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(scroll_text, Typography::muted())
|
||||
]));
|
||||
}
|
||||
let hidden_below = total_lines.saturating_sub(lines_for_content);
|
||||
if hidden_below > 0 {
|
||||
let more_line = ratatui::text::Line::from(vec![
|
||||
ratatui::text::Span::styled(format!("... {} more below", hidden_below), Typography::muted())
|
||||
]);
|
||||
visible_lines.push(more_line);
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(ratatui::text::Text::from(visible_lines));
|
||||
|
||||
@@ -9,7 +9,6 @@ use tracing::debug;
|
||||
|
||||
use super::Widget;
|
||||
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
||||
use crate::ui::CommandType;
|
||||
use ratatui::style::Style;
|
||||
|
||||
/// Services widget displaying hierarchical systemd service statuses
|
||||
@@ -125,41 +124,14 @@ impl ServicesWidget {
|
||||
)
|
||||
}
|
||||
|
||||
/// Get status icon for service, considering pending transitions for visual feedback
|
||||
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) -> (String, String, ratatui::prelude::Color) {
|
||||
// Check if this service has a pending transition
|
||||
if let Some((command_type, _original_status, _start_time)) = pending_transitions.get(service_name) {
|
||||
// Show transitional icons for pending commands
|
||||
let (icon, status_text) = match command_type {
|
||||
CommandType::ServiceStart => ("↑", "starting"),
|
||||
CommandType::ServiceStop => ("↓", "stopping"),
|
||||
_ => return (StatusIcons::get_icon(info.widget_status).to_string(), info.status.clone(), Theme::status_color(info.widget_status)), // Not a service command
|
||||
};
|
||||
return (icon.to_string(), status_text.to_string(), Theme::highlight());
|
||||
}
|
||||
|
||||
// Normal status display
|
||||
let icon = StatusIcons::get_icon(info.widget_status);
|
||||
let status_color = match info.widget_status {
|
||||
Status::Ok => Theme::success(),
|
||||
Status::Pending => Theme::highlight(),
|
||||
Status::Warning => Theme::warning(),
|
||||
Status::Critical => Theme::error(),
|
||||
Status::Unknown => Theme::muted_text(),
|
||||
Status::Offline => Theme::muted_text(),
|
||||
};
|
||||
|
||||
(icon.to_string(), info.status.clone(), status_color)
|
||||
}
|
||||
|
||||
|
||||
/// Create spans for sub-service with icon next to name, considering pending transitions
|
||||
fn create_sub_service_spans_with_transitions(
|
||||
/// Create spans for sub-service with icon next to name
|
||||
fn create_sub_service_spans(
|
||||
&self,
|
||||
name: &str,
|
||||
info: &ServiceInfo,
|
||||
is_last: bool,
|
||||
pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>,
|
||||
) -> Vec<ratatui::text::Span<'static>> {
|
||||
// Truncate long sub-service names to fit layout (accounting for indentation)
|
||||
let short_name = if name.len() > 18 {
|
||||
@@ -168,19 +140,28 @@ impl ServicesWidget {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
// Get status icon and text, considering pending transitions
|
||||
let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, pending_transitions);
|
||||
// Get status icon and text
|
||||
let icon = StatusIcons::get_icon(info.widget_status);
|
||||
let status_color = match info.widget_status {
|
||||
Status::Ok => Theme::success(),
|
||||
Status::Inactive => Theme::muted_text(),
|
||||
Status::Pending => Theme::highlight(),
|
||||
Status::Warning => Theme::warning(),
|
||||
Status::Critical => Theme::error(),
|
||||
Status::Unknown => Theme::muted_text(),
|
||||
Status::Offline => Theme::muted_text(),
|
||||
};
|
||||
|
||||
// For sub-services, prefer latency if available (unless transition is pending)
|
||||
if !pending_transitions.contains_key(name) {
|
||||
if let Some(latency) = info.latency_ms {
|
||||
status_str = if latency < 0.0 {
|
||||
"timeout".to_string()
|
||||
} else {
|
||||
format!("{:.0}ms", latency)
|
||||
};
|
||||
// For sub-services, prefer latency if available
|
||||
let status_str = if let Some(latency) = info.latency_ms {
|
||||
if latency < 0.0 {
|
||||
"timeout".to_string()
|
||||
} else {
|
||||
format!("{:.0}ms", latency)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info.status.clone()
|
||||
};
|
||||
let tree_symbol = if is_last { "└─" } else { "├─" };
|
||||
|
||||
vec![
|
||||
@@ -228,36 +209,13 @@ impl ServicesWidget {
|
||||
}
|
||||
|
||||
/// Get currently selected service name (for actions)
|
||||
/// Only returns parent service names since only parent services can be selected
|
||||
pub fn get_selected_service(&self) -> Option<String> {
|
||||
// Build the same display list to find the selected service
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new();
|
||||
|
||||
// Only parent services can be selected, so just get the parent service at selected_index
|
||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (parent_name, parent_info) in parent_services {
|
||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone()));
|
||||
|
||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
||||
let mut sorted_subs = sub_list.clone();
|
||||
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
||||
let is_last_sub = i == sorted_subs.len() - 1;
|
||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
||||
display_lines.push((
|
||||
sub_name.clone(),
|
||||
sub_info.widget_status,
|
||||
true,
|
||||
Some((sub_info.clone(), is_last_sub)),
|
||||
full_sub_name,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
display_lines.get(self.selected_index).map(|(_, _, _, _, raw_name)| raw_name.clone())
|
||||
parent_services.get(self.selected_index).map(|(name, _)| name.to_string())
|
||||
}
|
||||
|
||||
/// Get total count of selectable services (parent services only, not sub-services)
|
||||
@@ -266,25 +224,6 @@ impl ServicesWidget {
|
||||
self.parent_services.len()
|
||||
}
|
||||
|
||||
/// Get current status of a specific service by name
|
||||
pub fn get_service_status(&self, service_name: &str) -> Option<String> {
|
||||
// Check if it's a parent service
|
||||
if let Some(parent_info) = self.parent_services.get(service_name) {
|
||||
return Some(parent_info.status.clone());
|
||||
}
|
||||
|
||||
// Check sub-services (format: parent_sub)
|
||||
for (parent_name, sub_list) in &self.sub_services {
|
||||
for (sub_name, sub_info) in sub_list {
|
||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
||||
if full_sub_name == service_name {
|
||||
return Some(sub_info.status.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Calculate which parent service index corresponds to a display line index
|
||||
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
||||
@@ -439,8 +378,8 @@ impl Widget for ServicesWidget {
|
||||
|
||||
impl ServicesWidget {
|
||||
|
||||
/// Render with focus, scroll, and pending transitions for visual feedback
|
||||
pub fn render_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) {
|
||||
/// Render with focus
|
||||
pub fn render(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) {
|
||||
let services_block = Components::widget_block("services");
|
||||
let inner_area = services_block.inner(area);
|
||||
frame.render_widget(services_block, area);
|
||||
@@ -465,14 +404,14 @@ impl ServicesWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the existing render logic but with pending transitions
|
||||
self.render_services_with_transitions(frame, content_chunks[1], is_focused, scroll_offset, pending_transitions);
|
||||
// Render the services list
|
||||
self.render_services(frame, content_chunks[1], is_focused);
|
||||
}
|
||||
|
||||
/// Render services list with pending transitions awareness
|
||||
fn render_services_with_transitions(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, pending_transitions: &HashMap<String, (CommandType, String, std::time::Instant)>) {
|
||||
// Build hierarchical service list for display - include raw service name for pending transition lookups
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>, String)> = Vec::new(); // Added raw service name
|
||||
/// Render services list
|
||||
fn render_services(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) {
|
||||
// Build hierarchical service list for display
|
||||
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
||||
|
||||
// Sort parent services alphabetically for consistent order
|
||||
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
||||
@@ -481,7 +420,7 @@ impl ServicesWidget {
|
||||
for (parent_name, parent_info) in parent_services {
|
||||
// Add parent service line
|
||||
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None, parent_name.clone())); // Include raw name
|
||||
display_lines.push((parent_line, parent_info.widget_status, false, None));
|
||||
|
||||
// Add sub-services for this parent (if any)
|
||||
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
||||
@@ -491,49 +430,48 @@ impl ServicesWidget {
|
||||
|
||||
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
||||
let is_last_sub = i == sorted_subs.len() - 1;
|
||||
let full_sub_name = format!("{}_{}", parent_name, sub_name);
|
||||
// Store sub-service info for custom span rendering
|
||||
display_lines.push((
|
||||
sub_name.clone(),
|
||||
sub_info.widget_status,
|
||||
true,
|
||||
Some((sub_info.clone(), is_last_sub)),
|
||||
full_sub_name, // Raw service name for pending transition lookup
|
||||
)); // true = sub-service, with is_last info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply scroll offset and render visible lines (same as existing logic)
|
||||
// Show only what fits, with "X more below" if needed
|
||||
let available_lines = area.height as usize;
|
||||
let total_lines = display_lines.len();
|
||||
|
||||
// Calculate scroll boundaries
|
||||
let max_scroll = if total_lines > available_lines {
|
||||
total_lines - available_lines
|
||||
// Reserve one line for "X more below" if needed
|
||||
let lines_for_content = if total_lines > available_lines {
|
||||
available_lines.saturating_sub(1)
|
||||
} else {
|
||||
total_lines.saturating_sub(1)
|
||||
available_lines
|
||||
};
|
||||
let effective_scroll = scroll_offset.min(max_scroll);
|
||||
|
||||
// Get visible lines after scrolling
|
||||
let visible_lines: Vec<_> = display_lines
|
||||
.iter()
|
||||
.skip(effective_scroll)
|
||||
.take(available_lines)
|
||||
.take(lines_for_content)
|
||||
.collect();
|
||||
|
||||
let hidden_below = total_lines.saturating_sub(lines_for_content);
|
||||
|
||||
let lines_to_show = visible_lines.len();
|
||||
|
||||
if lines_to_show > 0 {
|
||||
// Add space for "X more below" message if needed
|
||||
let total_chunks_needed = if hidden_below > 0 { lines_to_show + 1 } else { lines_to_show };
|
||||
let service_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); lines_to_show])
|
||||
.constraints(vec![Constraint::Length(1); total_chunks_needed])
|
||||
.split(area);
|
||||
|
||||
for (i, (line_text, line_status, is_sub, sub_info, raw_service_name)) in visible_lines.iter().enumerate()
|
||||
for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate()
|
||||
{
|
||||
let actual_index = effective_scroll + i; // Real index in the full list
|
||||
let actual_index = i; // Simple index since we're not scrolling
|
||||
|
||||
// Only parent services can be selected - calculate parent service index
|
||||
let is_selected = if !*is_sub {
|
||||
@@ -545,41 +483,16 @@ impl ServicesWidget {
|
||||
};
|
||||
|
||||
let mut spans = if *is_sub && sub_info.is_some() {
|
||||
// Use custom sub-service span creation WITH pending transitions
|
||||
// Use custom sub-service span creation
|
||||
let (service_info, is_last) = sub_info.as_ref().unwrap();
|
||||
self.create_sub_service_spans_with_transitions(line_text, service_info, *is_last, pending_transitions)
|
||||
self.create_sub_service_spans(line_text, service_info, *is_last)
|
||||
} else {
|
||||
// Parent services - check if this parent service has a pending transition using RAW service name
|
||||
if pending_transitions.contains_key(raw_service_name) {
|
||||
// Create spans with transitional status
|
||||
let (icon, status_text, _) = self.get_service_icon_and_status(raw_service_name, &ServiceInfo {
|
||||
status: "".to_string(),
|
||||
memory_mb: None,
|
||||
disk_gb: None,
|
||||
latency_ms: None,
|
||||
widget_status: *line_status
|
||||
}, pending_transitions);
|
||||
|
||||
// Use blue for transitional icons when not selected, background color when selected
|
||||
let icon_color = if is_selected && !*is_sub && is_focused {
|
||||
Theme::background() // Dark background color for visibility against blue selection
|
||||
} else {
|
||||
Theme::highlight() // Blue for normal case
|
||||
};
|
||||
|
||||
vec![
|
||||
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(icon_color)),
|
||||
ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())),
|
||||
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(icon_color)),
|
||||
]
|
||||
} else {
|
||||
StatusIcons::create_status_spans(*line_status, line_text)
|
||||
}
|
||||
// Parent services - use normal status spans
|
||||
StatusIcons::create_status_spans(*line_status, line_text)
|
||||
};
|
||||
|
||||
// Apply selection highlighting to parent services only, making icons background color when selected
|
||||
// Apply selection highlighting to parent services only
|
||||
// Only show selection when Services panel is focused
|
||||
// Show selection highlighting even when transitional icons are present
|
||||
if is_selected && !*is_sub && is_focused {
|
||||
for (i, span) in spans.iter_mut().enumerate() {
|
||||
if i == 0 {
|
||||
@@ -600,33 +513,12 @@ impl ServicesWidget {
|
||||
|
||||
frame.render_widget(service_para, service_chunks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Show scroll indicator if there are more services than we can display (same as existing)
|
||||
if total_lines > available_lines {
|
||||
let hidden_above = effective_scroll;
|
||||
let hidden_below = total_lines.saturating_sub(effective_scroll + available_lines);
|
||||
|
||||
if hidden_above > 0 || hidden_below > 0 {
|
||||
let scroll_text = if hidden_above > 0 && hidden_below > 0 {
|
||||
format!("... {} above, {} below", hidden_above, hidden_below)
|
||||
} else if hidden_above > 0 {
|
||||
format!("... {} more above", hidden_above)
|
||||
} else {
|
||||
format!("... {} more below", hidden_below)
|
||||
};
|
||||
|
||||
if available_lines > 0 && lines_to_show > 0 {
|
||||
let last_line_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + (lines_to_show - 1) as u16,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let scroll_para = Paragraph::new(scroll_text).style(Typography::muted());
|
||||
frame.render_widget(scroll_para, last_line_area);
|
||||
}
|
||||
// Show "X more below" message if content was truncated
|
||||
if hidden_below > 0 {
|
||||
let more_text = format!("... {} more below", hidden_below);
|
||||
let more_para = Paragraph::new(more_text).style(Typography::muted());
|
||||
frame.render_widget(more_para, service_chunks[lines_to_show]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,8 +438,8 @@ impl Widget for SystemWidget {
|
||||
}
|
||||
|
||||
impl SystemWidget {
|
||||
/// Render with scroll offset support
|
||||
pub fn render_with_scroll(&mut self, frame: &mut Frame, area: Rect, scroll_offset: usize, hostname: &str, config: Option<&crate::config::DashboardConfig>) {
|
||||
/// Render system widget
|
||||
pub fn render(&mut self, frame: &mut Frame, area: Rect, hostname: &str, config: Option<&crate::config::DashboardConfig>) {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// NixOS section
|
||||
@@ -513,69 +513,30 @@ impl SystemWidget {
|
||||
Span::styled("Storage:", Typography::widget_title())
|
||||
]));
|
||||
|
||||
// Storage items with overflow handling
|
||||
// Storage items - let main overflow logic handle truncation
|
||||
let storage_lines = self.render_storage();
|
||||
let remaining_space = area.height.saturating_sub(lines.len() as u16);
|
||||
|
||||
if storage_lines.len() <= remaining_space as usize {
|
||||
// All storage lines fit
|
||||
lines.extend(storage_lines);
|
||||
} else if remaining_space >= 2 {
|
||||
// Show what we can and add overflow indicator
|
||||
let lines_to_show = (remaining_space - 1) as usize; // Reserve 1 line for overflow
|
||||
lines.extend(storage_lines.iter().take(lines_to_show).cloned());
|
||||
|
||||
// Count hidden pools
|
||||
let mut hidden_pools = 0;
|
||||
let mut current_pool = String::new();
|
||||
for (i, line) in storage_lines.iter().enumerate() {
|
||||
if i >= lines_to_show {
|
||||
// Check if this line represents a new pool (no indentation)
|
||||
if let Some(first_span) = line.spans.first() {
|
||||
let text = first_span.content.as_ref();
|
||||
if !text.starts_with(" ") && text.contains(':') {
|
||||
let pool_name = text.split(':').next().unwrap_or("").trim();
|
||||
if pool_name != current_pool {
|
||||
hidden_pools += 1;
|
||||
current_pool = pool_name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hidden_pools > 0 {
|
||||
let overflow_text = format!(
|
||||
"... and {} more pool{}",
|
||||
hidden_pools,
|
||||
if hidden_pools == 1 { "" } else { "s" }
|
||||
);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(overflow_text, Typography::muted())
|
||||
]));
|
||||
}
|
||||
}
|
||||
lines.extend(storage_lines);
|
||||
|
||||
// Apply scroll offset
|
||||
let total_lines = lines.len();
|
||||
let available_height = area.height as usize;
|
||||
|
||||
// Always apply scrolling if scroll_offset > 0, even if content fits
|
||||
if scroll_offset > 0 || total_lines > available_height {
|
||||
let max_scroll = if total_lines > available_height {
|
||||
total_lines - available_height
|
||||
} else {
|
||||
total_lines.saturating_sub(1)
|
||||
};
|
||||
let effective_scroll = scroll_offset.min(max_scroll);
|
||||
|
||||
// Take only the visible portion after scrolling
|
||||
let visible_lines: Vec<Line> = lines
|
||||
// Show only what fits, with "X more below" if needed
|
||||
if total_lines > available_height {
|
||||
let lines_for_content = available_height.saturating_sub(1); // Reserve one line for "more below"
|
||||
let mut visible_lines: Vec<Line> = lines
|
||||
.into_iter()
|
||||
.skip(effective_scroll)
|
||||
.take(available_height)
|
||||
.take(lines_for_content)
|
||||
.collect();
|
||||
|
||||
|
||||
let hidden_below = total_lines.saturating_sub(lines_for_content);
|
||||
if hidden_below > 0 {
|
||||
let more_line = Line::from(vec![
|
||||
Span::styled(format!("... {} more below", hidden_below), Typography::muted())
|
||||
]);
|
||||
visible_lines.push(more_line);
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(Text::from(visible_lines));
|
||||
frame.render_widget(paragraph, area);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-dashboard-shared"
|
||||
version = "0.1.73"
|
||||
version = "0.1.98"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -82,12 +82,13 @@ impl MetricValue {
|
||||
/// Health status for metrics
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Status {
|
||||
Ok,
|
||||
Pending,
|
||||
Warning,
|
||||
Critical,
|
||||
Unknown,
|
||||
Offline,
|
||||
Inactive, // Lowest priority
|
||||
Unknown, //
|
||||
Offline, //
|
||||
Pending, //
|
||||
Ok, // 5th place - good status has higher priority than unknown states
|
||||
Warning, //
|
||||
Critical, // Highest priority
|
||||
}
|
||||
|
||||
impl Status {
|
||||
@@ -181,6 +182,16 @@ impl HysteresisThresholds {
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
Status::Inactive => {
|
||||
// Inactive services use normal thresholds like first measurement
|
||||
if value >= self.critical_high {
|
||||
Status::Critical
|
||||
} else if value >= self.warning_high {
|
||||
Status::Warning
|
||||
} else {
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
Status::Pending => {
|
||||
// Service transitioning, use normal thresholds like first measurement
|
||||
if value >= self.critical_high {
|
||||
|
||||
Reference in New Issue
Block a user