diff --git a/Cargo.lock b/Cargo.lock index 477d530..5d7d55b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.18" @@ -166,6 +172,21 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[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.11" @@ -220,6 +241,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "config" version = "0.15.15" @@ -232,6 +267,66 @@ dependencies = [ "winnow", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "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 = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -243,6 +338,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "env_filter" version = "0.1.3" @@ -264,6 +365,22 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "fluxa" version = "0.2.2" @@ -271,8 +388,10 @@ dependencies = [ "axum", "clap", "config", + "crossterm", "env_logger", "log", + "ratatui", "reqwest", "serde", "serde_json", @@ -286,6 +405,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -347,6 +472,23 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.2.0" @@ -573,6 +715,12 @@ dependencies = [ "syn", ] +[[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.0.3" @@ -594,6 +742,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "io-uring" version = "0.7.8" @@ -627,6 +794,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" @@ -649,18 +825,43 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "matchit" version = "0.8.4" @@ -695,6 +896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.52.0", ] @@ -714,6 +916,35 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -847,6 +1078,36 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + [[package]] name = "reqwest" version = "0.12.22" @@ -912,6 +1173,19 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustls" version = "0.23.22" @@ -958,6 +1232,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.217" @@ -1017,6 +1297,36 @@ 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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.9" @@ -1064,12 +1374,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[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.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1298,6 +1636,35 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -1457,6 +1824,28 @@ dependencies = [ "rustls-pki-types", ] +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 0b3d86d..28da670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,12 @@ axum = { version = "0.8.4", default-features = false, features = [ ] } clap = "4.5.46" config = { version = "0.15.15", default-features = false, features = ["toml"] } +crossterm = "0.28" env_logger = { version = "0.11.8", default-features = false, features = [ "auto-color", ] } log = { version = "0.4.27", default-features = false } +ratatui = "0.29" reqwest = { version = "0.12.22", default-features = false, features = [ "json", "rustls-tls", @@ -30,7 +32,7 @@ reqwest = { version = "0.12.22", default-features = false, features = [ serde = { version = "1.0.217", default-features = false, features = ["derive"] } serde_json = { version = "1.0.143", default-features = false } thiserror = "2.0" -tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } [profile.release] opt-level = 'z' diff --git a/README.md b/README.md index 75520da..3b83e51 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,27 @@ cross-monitoring and ensure Fluxa itself remains up and running. ![fluxa >](https://raw.githubusercontent.com/rbas/fluxa/main/assets/fluxa.webp) +### Dashboard Mode + +The interactive dashboard provides real-time monitoring with a clean terminal interface: + +``` +┌Status───────────────────────────────────────────────────────────────────────────────────┐ +│Fluxa Dashboard - Filter: All | Sort: Url | Services: 3 │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ +┌MonitoredServices───────────────────────────────────────────────────────────────────────┐ +│URL Status Response Time Next Check Error │ +│https://example.com Healthy 150.23ms 2m15s - │ +│https://api.service.com Unhealthy N/A 5s HTTP 503: Serv… │ +│https://backup.service.com Healthy 89.45ms 1m30s - │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ +┌Help─────────────────────────────────────────────────────────────────────────────────────┐ +│q/Esc: Quit | f: Filter | s: Sort | ↑/k: Up | ↓/j: Down │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +*Dashboard showing real-time monitoring status with color-coded health indicators* + ## 🚀 Fluxa Features * **Lightweight and Efficient:** Fluxa has very low RAM usage (in units of MB), @@ -22,6 +43,10 @@ making it ideal for low-resource environments. services like Uptime Kuma, but flexible enough to monitor any service by URL. * **Minimal Footprint:** Fluxa is a small, reliable service designed to run efficiently without consuming excessive resources. +* **Interactive Dashboard:** Real-time terminal UI dashboard showing service status, +response times, and monitoring progress with vim-like key bindings. +* **Dual Operation Modes:** Choose between headless mode for server deployments +or dashboard mode for interactive monitoring. ## 🛠 Usage @@ -29,17 +54,62 @@ Once **Fluxa** is installed and the configuration file is ready, you can run it by executing the binary with the `--config` parameter, followed by the path to your configuration file. -For example: +### Headless Mode (Default) + +For headless monitoring with log output: ```shell ./fluxa --config /path/to/your/config.toml ``` -This will start **Fluxa**, and it will begin monitoring the services defined in your +This will start **Fluxa** in headless mode, and it will begin monitoring the services defined in your configuration file. Additionally, Fluxa's internal web server will be running at the configured listen address (e.g., `127.0.0.1:8080`) to allow cross-monitoring of *Fluxa* itself. +### Dashboard Mode + +For interactive terminal dashboard monitoring: + +```shell +./fluxa --config /path/to/your/config.toml --dashboard +``` + +Or using the short form: + +```shell +./fluxa -c /path/to/your/config.toml -d +``` + +The dashboard mode provides a real-time terminal UI that displays: + +- **All monitored URLs** with current status (Healthy/Unhealthy) +- **Response times** in milliseconds for successful requests +- **Next check countdown** showing when the next monitoring check will occur +- **Error messages** for failed requests or connectivity issues +- **Interactive filtering and sorting** options + +#### Dashboard Key Bindings (Vim-style) + +- **q** or **Esc**: Quit the dashboard +- **f**: Toggle filter modes (All → Healthy Only → Unhealthy Only → All) +- **s**: Toggle sort modes (URL → Status → Response Time → Next Check → URL) +- **k** or **↑**: Move selection up +- **j** or **↓**: Move selection down + +#### Filter Modes + +- **All**: Show all monitored services +- **Healthy Only**: Show only services that are currently healthy +- **Unhealthy Only**: Show only services that are currently down or failing + +#### Sort Modes + +- **URL**: Sort alphabetically by service URL +- **Status**: Sort by health status (unhealthy services first) +- **Response Time**: Sort by response time (fastest first) +- **Next Check**: Sort by next check time (soonest first) + ## 📦 Installation You can install Fluxa in two ways: by downloading a pre-compiled binary diff --git a/src/dashboard.rs b/src/dashboard.rs new file mode 100644 index 0000000..7b7bfa2 --- /dev/null +++ b/src/dashboard.rs @@ -0,0 +1,357 @@ +use std::{ + collections::HashMap, + io::{self, Stdout}, + time::{Duration, Instant}, +}; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Row, Table, Cell}, + Terminal, Frame, +}; +use tokio::sync::mpsc; + +use crate::model::{HealthStatus, MonitoredService}; + +#[derive(Debug, Clone)] +pub enum DashboardEvent { + ServiceUpdate(String, MonitoredService), // URL, Updated service +} + +#[derive(Debug, Clone)] +pub enum FilterMode { + All, + HealthyOnly, + UnhealthyOnly, +} + +#[derive(Debug, Clone)] +pub enum SortMode { + Url, + Status, + ResponseTime, + NextCheck, +} + +pub struct DashboardState { + services: HashMap, + filter_mode: FilterMode, + sort_mode: SortMode, + selected_index: usize, + last_update: Instant, +} + +impl DashboardState { + pub fn new() -> Self { + Self { + services: HashMap::new(), + filter_mode: FilterMode::All, + sort_mode: SortMode::Url, + selected_index: 0, + last_update: Instant::now(), + } + } + + pub fn update_service(&mut self, url: String, service: MonitoredService) { + self.services.insert(url, service); + self.last_update = Instant::now(); + } + + pub fn get_filtered_services(&self) -> Vec<&MonitoredService> { + let mut filtered: Vec<&MonitoredService> = self.services.values() + .filter(|service| match self.filter_mode { + FilterMode::All => true, + FilterMode::HealthyOnly => service.health_status == HealthStatus::Healthy, + FilterMode::UnhealthyOnly => service.health_status == HealthStatus::Unhealthy, + }) + .collect(); + + match self.sort_mode { + SortMode::Url => filtered.sort_by(|a, b| a.url.cmp(&b.url)), + SortMode::Status => filtered.sort_by(|a, b| { + match (&a.health_status, &b.health_status) { + (HealthStatus::Unhealthy, HealthStatus::Healthy) => std::cmp::Ordering::Less, + (HealthStatus::Healthy, HealthStatus::Unhealthy) => std::cmp::Ordering::Greater, + _ => a.url.cmp(&b.url), + } + }), + SortMode::ResponseTime => filtered.sort_by(|a, b| { + match (a.response_time, b.response_time) { + (Some(a_time), Some(b_time)) => a_time.cmp(&b_time), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.url.cmp(&b.url), + } + }), + SortMode::NextCheck => filtered.sort_by(|a, b| { + match (a.next_check, b.next_check) { + (Some(a_check), Some(b_check)) => a_check.cmp(&b_check), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.url.cmp(&b.url), + } + }), + } + + filtered + } + + pub fn toggle_filter(&mut self) { + self.filter_mode = match self.filter_mode { + FilterMode::All => FilterMode::HealthyOnly, + FilterMode::HealthyOnly => FilterMode::UnhealthyOnly, + FilterMode::UnhealthyOnly => FilterMode::All, + }; + self.selected_index = 0; // Reset selection when filter changes + } + + pub fn toggle_sort(&mut self) { + self.sort_mode = match self.sort_mode { + SortMode::Url => SortMode::Status, + SortMode::Status => SortMode::ResponseTime, + SortMode::ResponseTime => SortMode::NextCheck, + SortMode::NextCheck => SortMode::Url, + }; + self.selected_index = 0; // Reset selection when sort changes + } + + pub fn select_previous(&mut self) { + let filtered_count = self.get_filtered_services().len(); + if filtered_count > 0 { + self.selected_index = if self.selected_index == 0 { + filtered_count - 1 + } else { + self.selected_index - 1 + }; + } + } + + pub fn select_next(&mut self) { + let filtered_count = self.get_filtered_services().len(); + if filtered_count > 0 { + self.selected_index = (self.selected_index + 1) % filtered_count; + } + } +} + +pub struct Dashboard { + terminal: Terminal>, + state: DashboardState, + rx: mpsc::UnboundedReceiver, +} + +impl Dashboard { + pub fn new(rx: mpsc::UnboundedReceiver) -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + + Ok(Self { + terminal, + state: DashboardState::new(), + rx, + }) + } + + pub async fn run(&mut self) -> Result<(), io::Error> { + loop { + // Handle incoming events from monitoring tasks + while let Ok(event) = self.rx.try_recv() { + match event { + DashboardEvent::ServiceUpdate(url, service) => { + self.state.update_service(url, service); + } + } + } + + // Draw the dashboard + let state = &self.state; // Create a reference to avoid borrowing issues + self.terminal.draw(|f| draw_dashboard(f, state))?; + + // Handle keyboard input + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Char('f') => self.state.toggle_filter(), + KeyCode::Char('s') => self.state.toggle_sort(), + KeyCode::Up | KeyCode::Char('k') => self.state.select_previous(), + KeyCode::Down | KeyCode::Char('j') => self.state.select_next(), + _ => {} + } + } + } + } + } + + Ok(()) + } +} + +fn draw_dashboard(frame: &mut Frame, state: &DashboardState) { + let chunks = Layout::vertical([ + Constraint::Length(3), // Header + Constraint::Min(10), // Main table + Constraint::Length(3), // Footer + ]).split(frame.area()); + + draw_header(frame, chunks[0], state); + draw_services_table(frame, chunks[1], state); + draw_footer(frame, chunks[2]); +} + +fn draw_header(frame: &mut Frame, area: Rect, state: &DashboardState) { + let title = format!( + "Fluxa Dashboard - Filter: {:?} | Sort: {:?} | Services: {}", + state.filter_mode, + state.sort_mode, + state.services.len() + ); + + let header = Paragraph::new(title) + .style(Style::default().fg(Color::Yellow)) + .block(Block::default().borders(Borders::ALL).title("Status")); + + frame.render_widget(header, area); +} + +fn draw_services_table(frame: &mut Frame, area: Rect, state: &DashboardState) { + let filtered_services = state.get_filtered_services(); + + let header_cells = vec![ + Cell::from("URL").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Status").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Response Time").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Next Check").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Error").style(Style::default().add_modifier(Modifier::BOLD)), + ]; + let header = Row::new(header_cells) + .style(Style::default().bg(Color::Blue)) + .height(1); + + let rows: Vec = filtered_services + .iter() + .enumerate() + .map(|(i, service)| { + let status_style = match service.health_status { + HealthStatus::Healthy => Style::default().fg(Color::Green), + HealthStatus::Unhealthy => Style::default().fg(Color::Red), + }; + + let response_time_str = service.response_time + .map(|rt| format!("{:.2}ms", rt.as_secs_f64() * 1000.0)) + .unwrap_or_else(|| "N/A".to_string()); + + let next_check_str = service.next_check + .map(|next| { + let now = Instant::now(); + if next > now { + let remaining = next - now; + format_duration(remaining) + } else { + "Now".to_string() + } + }) + .unwrap_or_else(|| "N/A".to_string()); + + let error_str = service.error_message + .as_ref() + .map(|e| truncate_string(e, 30)) + .unwrap_or_else(|| "-".to_string()); + + let cells = vec![ + Cell::from(truncate_string(&service.url, 40)), + Cell::from(format!("{:?}", service.health_status)).style(status_style), + Cell::from(response_time_str), + Cell::from(next_check_str), + Cell::from(error_str).style(Style::default().fg(Color::Red)), + ]; + + let row_style = if i == state.selected_index { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + Row::new(cells).style(row_style) + }) + .collect(); + + let table = Table::new(rows, [ + Constraint::Percentage(30), // URL + Constraint::Percentage(15), // Status + Constraint::Percentage(15), // Response Time + Constraint::Percentage(20), // Next Check + Constraint::Percentage(20), // Error + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title("Monitored Services")) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + frame.render_widget(table, area); +} + +fn draw_footer(frame: &mut Frame, area: Rect) { + let help_text = vec![ + Line::from(vec![ + Span::styled("q/Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": Quit | "), + Span::styled("f", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": Filter | "), + Span::styled("s", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": Sort | "), + Span::styled("↑/k", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": Up | "), + Span::styled("↓/j", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": Down"), + ]), + ]; + + let footer = Paragraph::new(help_text) + .style(Style::default().fg(Color::Gray)) + .block(Block::default().borders(Borders::ALL).title("Help")); + + frame.render_widget(footer, area); +} + +impl Drop for Dashboard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ); + let _ = self.terminal.show_cursor(); + } +} + +fn format_duration(duration: Duration) -> String { + let secs = duration.as_secs(); + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m{}s", secs / 60, secs % 60) + } else { + format!("{}h{}m", secs / 3600, (secs % 3600) / 60) + } +} + +fn truncate_string(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len.saturating_sub(3)]) + } +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index 1aac416..37e2f1a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,6 +19,9 @@ pub enum FluxaError { #[error("Address parsing error: {0}")] AddrParse(#[from] std::net::AddrParseError), + + #[error("Other error: {0}")] + Other(String), } /// Service monitoring and operation errors diff --git a/src/lib.rs b/src/lib.rs index 31c6542..306e2de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod dashboard; pub mod error; pub mod http; pub mod model; diff --git a/src/main.rs b/src/main.rs index d942531..0526b3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use clap::{builder::PathBufValueParser, Arg, Command}; use fluxa::{ + dashboard::{Dashboard, DashboardEvent}, error::FluxaError, http::spawn_web_server, notification::Notifier, @@ -9,6 +10,7 @@ use fluxa::{ settings::FluxaConfig, }; use log::info; +use tokio::sync::mpsc; #[tokio::main] async fn main() -> Result<(), FluxaError> { @@ -25,10 +27,18 @@ async fn main() -> Result<(), FluxaError> { .help("Path to configuration file") .value_parser(PathBufValueParser::default()), ) + .arg( + Arg::new("dashboard") + .short('d') + .long("dashboard") + .help("Run in dashboard mode with terminal UI") + .action(clap::ArgAction::SetTrue), + ) .get_matches(); let default_file = PathBuf::from("config.local.toml"); let config_path = matches.get_one("config").unwrap_or(&default_file); + let dashboard_mode = matches.get_flag("dashboard"); let conf = FluxaConfig::new(config_path.as_path())?; @@ -42,24 +52,54 @@ async fn main() -> Result<(), FluxaError> { info!("Spawning monitoring"); + // Create dashboard channel if in dashboard mode + let (dashboard_tx, dashboard_rx) = if dashboard_mode { + let (tx, rx) = mpsc::unbounded_channel::(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + // Spawn monitoring tasks let mut handles = vec![]; for service in services { let notifier_clone = notifier.clone(); + let dashboard_tx_clone = dashboard_tx.clone(); let handle = tokio::spawn(async move { - monitor_url(service, notifier_clone).await + monitor_url(service, notifier_clone, dashboard_tx_clone).await // TODO Handle errors }); handles.push(handle); } - // Spawning web server for monitoring that service is alive - let socket_addr = conf.fluxa.listen.as_str(); - spawn_web_server(socket_addr).await?; + if dashboard_mode { + // Run dashboard + if let Some(rx) = dashboard_rx { + let mut dashboard = Dashboard::new(rx).map_err(|e| FluxaError::Other(e.to_string()))?; + + // Spawn web server in background if needed + let socket_addr = conf.fluxa.listen.clone(); + tokio::spawn(async move { + if let Err(e) = spawn_web_server(&socket_addr).await { + eprintln!("Web server error: {}", e); + } + }); + + // Run dashboard (this will block until user exits) + if let Err(e) = dashboard.run().await { + eprintln!("Dashboard error: {}", e); + } + } + } else { + // Original headless mode + // Spawning web server for monitoring that service is alive + let socket_addr = conf.fluxa.listen.as_str(); + spawn_web_server(socket_addr).await?; - // Wait for all tasks to complete (they will run indefinitely) - for handle in handles { - let _ = handle.await?; + // Wait for all tasks to complete (they will run indefinitely) + for handle in handles { + let _ = handle.await?; + } } Ok(()) diff --git a/src/model.rs b/src/model.rs index d700f1f..c19885a 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::time::{Duration, Instant}; use reqwest::Url; use thiserror::Error; @@ -17,13 +17,17 @@ pub enum MonitoredServiceError { InvalidUrl(String), } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct MonitoredService { pub url: String, pub interval_seconds: u64, pub health_status: HealthStatus, pub max_retries: usize, pub retry_interval: Duration, + pub last_check: Option, + pub next_check: Option, + pub response_time: Option, + pub error_message: Option, } impl MonitoredService { @@ -37,14 +41,28 @@ impl MonitoredService { if !is_valid_url(&url) { return Err(MonitoredServiceError::InvalidUrl(url)); } + let now = Instant::now(); Ok(Self { url, interval_seconds, health_status, max_retries, retry_interval, + last_check: None, + next_check: Some(now + Duration::from_secs(interval_seconds)), + response_time: None, + error_message: None, }) } + + pub fn update_after_check(&mut self, health_status: HealthStatus, response_time: Option, error_message: Option) { + let now = Instant::now(); + self.health_status = health_status; + self.last_check = Some(now); + self.next_check = Some(now + Duration::from_secs(self.interval_seconds)); + self.response_time = response_time; + self.error_message = error_message; + } } fn is_valid_url(input: &str) -> bool { diff --git a/src/service.rs b/src/service.rs index 634705b..eeabbd2 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,8 +1,10 @@ use log::{debug, error, info, warn}; use reqwest::Client; -use tokio::time::{self, Duration}; +use tokio::time::{self, Duration, Instant}; +use tokio::sync::mpsc; use crate::{ + dashboard::DashboardEvent, error::ServiceError, model::{HealthStatus, MonitoredService}, notification::Notifier, @@ -13,15 +15,28 @@ async fn send_request( client: &Client, service: &mut MonitoredService, notifier: &Notifier, + dashboard_tx: Option<&mpsc::UnboundedSender>, ) -> Result { let mut current_health = HealthStatus::Unhealthy; + let mut response_time = None; + let mut error_message = None; + let previous_health = service.health_status.clone(); + for attempt in 0..=service.max_retries { + let start_time = Instant::now(); + match client.get(&service.url).send().await { Ok(response) => { + let elapsed = start_time.elapsed(); + response_time = Some(elapsed); + if response.status().is_success() { current_health = HealthStatus::Healthy; + error_message = None; break; } else { + let error_msg = format!("HTTP {}: {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown")); + error_message = Some(error_msg.clone()); debug!( "Request to {} failed with status: {}", service.url, @@ -29,7 +44,8 @@ async fn send_request( ); } } - Err(_) => { + Err(e) => { + error_message = Some(format!("Request failed: {}", e)); if attempt < service.max_retries { debug!( "Attempt {} to {} failed. Retrying in {:?}...", @@ -50,7 +66,16 @@ async fn send_request( } } - if current_health != service.health_status { + // Update service with new information + service.update_after_check(current_health.clone(), response_time, error_message.clone()); + + // Send update to dashboard if channel exists + if let Some(tx) = dashboard_tx { + let _ = tx.send(DashboardEvent::ServiceUpdate(service.url.clone(), service.clone())); + } + + // Check if status changed and send notifications + if current_health != previous_health { if current_health == HealthStatus::Healthy { let message = format!("{} is now healthy!", service.url); info!("{}", &message); @@ -70,7 +95,6 @@ async fn send_request( error!("Problem with PushOver service {:?}", result.err()); } } - service.health_status = current_health.clone(); } Ok(current_health) @@ -79,9 +103,10 @@ async fn send_request( pub async fn monitor_url( mut service: MonitoredService, notifier: Notifier, + dashboard_tx: Option>, ) -> Result<(), ServiceError> { loop { - send_request(&Client::new(), &mut service, ¬ifier).await?; + send_request(&Client::new(), &mut service, ¬ifier, dashboard_tx.as_ref()).await?; time::sleep(Duration::from_secs(service.interval_seconds)).await; } }