diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3b18485 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: clippy, rustfmt + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..70ac144 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,139 @@ +# Changelog + +## [0.2.1] - 2026-06-18 + +### Config: Persistencia de Layouts Personalizados +- `Config` ahora tiene campo `layout_name` que almacena el nombre del layout seleccionado (soporta layouts mas alla de los 7 built-in `LayoutMode`) +- En configuraciones existentes, `layout_name` es opcional (`#[serde(default)]`) y se usa como respaldo `layout_mode` +- `save_config` guarda el nombre del layout actual por su indice en `layout_defs`, permitiendo restaurar layouts personalizados al reiniciar +- `AppState::new` prioriza `config.layout_name` si no es vacio, con fallback a `layout_index_from_mode` +- Config se guarda automaticamente al seleccionar tema o layout desde la paleta de comandos + +### Paleta de Comandos: Soporte macOS +- Agregada ruta directa para `ctrl+p` en el bucle de eventos, independiente del sistema de keybindings (soluciona problemas donde crossterm reporta la tecla con distinto casing o el keybinding no se resuelve) +- `key_event_to_str` aplica `to_ascii_lowercase()` al caracter cuando Ctrl esta activo, normalizando `"ctrl+P"` a `"ctrl+p"` +- Agregado `"ctrl+P"` como variante alternativa en el keybinding por defecto +- Debug output de teclas en compilaciones debug (`cargo build` sin `--release`) para diagnosticar problemas: `[key] 'ctrl+p'` + +### Plugins: Seguridad y Arquitectura +- `PluginContext` ahora verifica capabilities antes de ejecutar acciones sensibles: `kill_process`, `set_alert_thresholds`, `set_theme_by_name`, `set_layout_by_name`, `set_update_interval` requieren `KillProcesses` o `ModifyConfig` segun corresponda +- `PluginCapability::Custom` migrado de `&'static str` a `String` para flexibilidad +- `PluginManifest` usa `String` en vez de `&'static str`, permitiendo plugins que generen metadata dinamicamente +- `#[non_exhaustive]` agregado a `PluginCapability` para evolucion segura del enum + +### PluginManager: Robustez +- `with_plugin_manager_mut()` reemplaza el patron inseguro `take()` + `Some()` que podia perder el manager si una ruta de error no lo restauraba. Todos los callers migrados: `on_tick`, manejo de teclas, shutdown, y MCP server +- `register()` ahora devuelve `Result<(), PluginError>` en vez de `Result<(), String>`, consistente con el resto del sistema +- Metodo `build_context()` elimina la duplicacion de construccion de `PluginContext` en 5 metodos distintos +- `data_dir` del plugin ahora apunta al directorio del plugin (`plugins//`), no a `plugins//config.json` + +### Capabilities: Validacion Real +- `collect_widgets()` solo acepta widgets si el plugin declara `RenderWidgets` +- `collect_data_providers()` solo acepta providers si el plugin declara `ReadSystemInfo` +- `PluginContext` inyecta las capabilities declaradas y verifica en cada metodo sensible + +### MCP Server +- Migrado de `take()`/`Some()` a `with_plugin_manager_mut()` para seguridad +- Eliminado doble `tick_all()` innecesario en el handler de herramientas + +### Sistema de Providers +- `SystemDataProvider` ahora tiene metodo `add_extras()` con default no-op, eliminando la necesidad de `downcast_mut::()` +- `kill_process()` verifica que el UID del proceso coincida con el usuario actual antes de enviar SIGTERM +- Limite de procesos configurable via `SysinfoProvider::max_processes` (default 200) +- Eliminados `NoopBatteryProvider`, `NoopGpuProvider`, `NoopDockerProvider` — codigo muerto no utilizado + +### Sentinel: Calidad de Datos +- Migracion completa de construccion manual de JSON (`format!` con strings escapados) a `serde_json::json!()` — elimina riesgo de inyeccion JSON y corrupcion por caracteres especiales + +### TUI +- `LayoutConstraint::Fill` mapeado correctamente a `ratatui::Constraint::Fill(1)` en vez de `Min(0)` + +### Infraestructura +- `config_dir()` centralizada en `xtop_core::infrastructure::config::config_dir()` — eliminada duplicacion en 5 modulos +- CI workflow creado en `.github/workflows/ci.yml`: `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` + +## [0.2.0] - 2026-06-03 + +### Refactorizacion Total del Proyecto +- Migrado a workspace multi-crate: `xtop-core`, `xtop-tui`, `xtop-cli` +- Eliminado el monolito `src/` — ahora cada capa vive en su propio crate +- Eliminadas 5 dependencias muertas: `serde`, `serde_json`, `clap`, `chrono`, `tokio` (se redujo de ~84 a ~55 crates) +- Eliminado codigo muerto: `InputMode`, `show_help`, `swap_history`, `process_table_state`, `graph_colors()` +- Eliminados todos los `#[allow(dead_code)]` + +### Nueva Arquitectura Hexagonal +- Capa de Dominio (`xtop-core/domain/`): modelos de datos puros + trait `SystemDataProvider` +- Capa de Aplicacion (`xtop-core/application/`): `AppState`, `MetricsHistory` (con `VecDeque`), `LayoutMode`, `EffectiveLayout` +- Capa de Infraestructura (`xtop-core/infrastructure/`): `SysinfoProvider`, `theme_loader`, `config`, providers stub +- Capa de Presentacion (`xtop-tui/`): terminal, render widgets separados, format helpers +- Binary (`xtop-cli/`): entry point con inyeccion de dependencias + +### Layout Responsive +- `detect_effective_layout(width, height, mode)` adapta el layout automaticamente: + - **Dashboard** (>100x30): layout completo 2-columnas + - **Compact** (>80x24): mas compacto + - **Vertical** (<80): todo apilado + - **Minimal** (<60 ancho o <18 alto): solo CPU + Mem + procesos + - **Too Small** (<40x8): mensaje de advertencia + +### Nuevos Layouts (7 modos, ciclo con `l`) +| Modo | Descripcion | +|------|-------------| +| Dashboard | Default, 2-columnas con graficos | +| Vertical | Apilado, para terminales estrechas | +| Horizontal | 4 columnas: CPU/Mem/Storage/Network | +| CPU Focus | CPU grande + procesos | +| Memory Focus | Memoria grande con chart + procesos | +| Network Focus | Network + Disk I/O lado a lado + procesos | +| Process Focus | Stats pequenos + procesos maximizados | + +### Full Screen (`f` / `F`) +- `f` activa/desactiva modo fullscreen +- `F` cicla entre widgets (CPU, Memory, Storage, Network, Processes, Disk I/O, GPU, Battery, salir) +- Widget seleccionado ocupa toda la terminal (menos header) + +### Busqueda de Procesos (`/`) +- Filtrado en tiempo real por nombre de proceso +- `Enter` confirma el filtro, `Esc` cancela, `Backspace` borra +- Overlay centrado con indicador `/query_` + +### Ayuda en Pantalla (`?`) +- Muestra todas las keybindings disponibles +- Cierra con `Esc` o `?` otra vez + +### Nuevas Metricas +- **Disk I/O**: velocidad de lectura/escritura por disco (bytes/s) con widget dedicado +- **Per-interface Network**: RX/TX y velocidad por interfaz de red +- **GPU**, **Battery**, **Docker** stubs preparados para implementacion futura + +### Alertas por Threshold +- **CPU > 90%**: color cambia a rojo +- **Memoria > 90%**: color rojo + icono de advertencia en el titulo +- Thresholds configurables en `AlertThresholds` (cpu_high, mem_high, disk_high) + +### Mejoras de Codigo +- `Vec` + `remove(0)` reemplazado por `VecDeque` con `pop_front()` (O(1)) +- Helper `format_bytes()` elimina repeticion de `1024.0 / 1024.0 / 1024.0` +- Helper `format_uptime()` para formato legible de tiempo activo +- `MetricsHistory::set_max_points()` para configurar puntos del historico + +### Configuracion Persistente +- `~/.config/xtop/config.json`: guarda tema, layout, intervalo, history_points, alerts +- `~/.config/xtop/themes/*.json`: temas personalizados por el usuario +- Guardado automatico al salir con `q` +- Temas built-in (13) se fusionan con temas personalizados + +### Tests +- 39 tests unitarios (de 0): layout detection, history, themes, format helpers, config +- CI workflow `.github/workflows/ci.yml`: check, fmt, clippy, test, build + +### Keybindings Completos +| Tecla | Accion | +|-------|--------| +| `q` | Salir (guarda config) | +| `?` | Ayuda | +| `t` / `T` | Siguiente/anterior tema | +| `l` | Siguiente layout | +| `f` / `F` | Toggle fullscreen / ciclar widget | +| `/` | Buscar procesos | +| `Esc` | Cancelar busqueda / cerrar ayuda | diff --git a/Cargo.lock b/Cargo.lock index f7c8009..e6b6971 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,99 +3,31 @@ version = 4 [[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" +name = "aho-corasick" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "windows-sys 0.61.2", + "memchr", ] [[package]] -name = "anstyle-wincon" -version = "3.0.11" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "autocfg" -version = "1.5.0" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bumpalo" -version = "3.19.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "cassowary" @@ -112,86 +44,17 @@ dependencies = [ "rustversion", ] -[[package]] -name = "cc" -version = "1.2.51" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" -dependencies = [ - "find-msvc-tools", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "clap" -version = "4.5.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - [[package]] name = "compact_str" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" dependencies = [ "castaway", "cfg-if", @@ -201,37 +64,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - [[package]] name = "crossterm" version = "0.28.1" @@ -291,11 +123,21 @@ dependencies = [ "syn", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "equivalent" @@ -313,12 +155,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "find-msvc-tools" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" - [[package]] name = "foldhash" version = "0.1.5" @@ -336,6 +172,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -343,35 +185,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "iana-time-zone" -version = "0.1.64" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "cc", + "equivalent", + "hashbrown 0.17.1", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "indoc" version = "2.0.7" @@ -383,9 +211,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", @@ -394,12 +222,6 @@ dependencies = [ "syn", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.13.0" @@ -411,25 +233,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "js-sys" -version = "0.3.83" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" @@ -448,9 +260,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" [[package]] name = "lru" @@ -458,20 +270,20 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -481,33 +293,69 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "autocfg", + "objc2-encode", ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] [[package]] name = "parking_lot" @@ -538,26 +386,20 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -584,34 +426,43 @@ dependencies = [ ] [[package]] -name = "rayon" -version = "1.11.0" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "either", - "rayon-core", + "bitflags", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "regex" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ - "bitflags", + "aho-corasick", + "memchr", + "regex-syntax", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustix" version = "0.38.44" @@ -633,9 +484,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "scopeguard" @@ -675,9 +526,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -687,10 +538,13 @@ dependencies = [ ] [[package]] -name = "shlex" -version = "1.3.0" +name = "serde_spanned" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] [[package]] name = "signal-hook" @@ -729,16 +583,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -775,9 +619,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.113" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -786,57 +630,71 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.33.1" +version = "0.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" dependencies = [ - "core-foundation-sys", "libc", "memchr", "ntapi", - "rayon", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", "windows", ] [[package]] -name = "tokio" -version = "1.49.0" +name = "toml" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", ] [[package]] -name = "tokio-macros" -version = "2.6.0" +name = "toml_datetime" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ - "proc-macro2", - "quote", - "syn", + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -861,63 +719,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] - [[package]] name = "winapi" version = "0.3.9" @@ -942,24 +749,23 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.57.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] -name = "windows-core" -version = "0.57.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -968,22 +774,22 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] [[package]] -name = "windows-implement" -version = "0.57.0" +name = "windows-future" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -997,17 +803,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -1026,12 +821,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-result" -version = "0.1.2" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets 0.52.6", + "windows-core", + "windows-link", ] [[package]] @@ -1058,16 +854,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -1085,31 +872,23 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "windows-targets" -version = "0.53.5" +name = "windows-threading" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1118,84 +897,42 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1203,28 +940,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "xtop" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", - "chrono", - "clap", "crossterm", + "serde_json", + "toml", + "xtop-core", + "xtop-plugin-sentinel", + "xtop-tui", +] + +[[package]] +name = "xtop-core" +version = "0.2.0" +dependencies = [ "ratatui", "serde", "serde_json", "sysinfo", - "tokio", +] + +[[package]] +name = "xtop-plugin-sentinel" +version = "0.2.0" +dependencies = [ + "anyhow", + "ratatui", + "regex", + "serde", + "serde_json", + "xtop-core", +] + +[[package]] +name = "xtop-tui" +version = "0.2.0" +dependencies = [ + "crossterm", + "ratatui", + "xtop-core", ] [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 5c10f1a..11409cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,21 @@ -[package] -name = "xtop" -version = "0.1.0" +[workspace] +resolver = "2" +members = [ + "crates/xtop-core", + "crates/xtop-tui", + "crates/xtop-cli", + "plugins/xtop-plugin-sentinel", +] + +[workspace.package] +version = "0.2.0" edition = "2021" +license = "MIT" -[dependencies] -ratatui = "0.29.0" -crossterm = "0.28.1" -sysinfo = "0.33.0" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -anyhow = "1.0" -clap = { version = "4.5", features = ["derive"] } -tokio = { version = "1.0", features = ["full"] } -chrono = "0.4" +[workspace.dependencies] +ratatui = "0.29" +crossterm = "0.28" +sysinfo = "0.39" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/README.md b/README.md index d1ffbb4..fe2f2b3 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,57 @@ -

Xtop

+

Xtop

-![rust](https://xscriptor.github.io/badges/languages/rust.svg) ![mit](https://xscriptor.github.io/badges/licenses/mit.svg) ![shell](https://xscriptor.github.io/badges/languages/shell.svg) ![powershell](https://xscriptor.github.io/badges/languages/powershell.svg) ![xtop](https://xscriptor.github.io/badges/software/xtop.svg) +![Rust](https://img.shields.io/badge/Rust-1.80%2B-orange) +![License](https://img.shields.io/badge/license-MIT-blue) +![CI](https://img.shields.io/github/actions/workflow/status/xscriptor/xtop/ci.yml?branch=main) +![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey) +![ratatui](https://img.shields.io/badge/built%20with-ratatui-red) -xtop is a modern, cross-platform TUI system monitor crafted in Rust. Heavily inspired by btop, it leverages Rust's safety and performance, powered by ratatui for the interface and sysinfo for real-time metrics. +A cross-platform TUI system monitor written in Rust. Uses ratatui for the terminal interface and sysinfo for real-time system metrics.
-

Xscriptor logo

+

XTop logo

---- +
-# Previews +

Contents

+ + + +
+ +

Features

+ +
    +
  • CPU usage per core with temperature sensing
  • +
  • RAM and Swap monitoring with historical chart
  • +
  • Network RX/TX tracking per interface
  • +
  • Storage and Disk I/O visualization
  • +
  • Process list with live search
  • +
  • GPU and Battery monitoring (stub, ready for supported hardware)
  • +
  • 13 color themes with custom theme support via JSONC
  • +
  • 7 built-in layouts with custom layout support via JSONC
  • +
  • Full-screen mode for any widget
  • +
  • Configurable alert thresholds
  • +
  • Persistent configuration
  • +
+ +

See docs/features.md for a detailed feature breakdown.

+ +
+ +

Previews

@@ -20,149 +59,81 @@ xtop is a modern, cross-platform TUI system monitor crafted in Rust. Heavily ins

-
- More previews - - - - - - - - - - -
- - Preview 2 - - - - Preview 3 - -
- - Preview 4 - - -
-
- -## Features - -- **Cross-Platform:** Runs on macOS, Linux, and Windows. -- **System Monitoring:** - - **CPU:** Usage per core/thread, maximum temperature sensing. - - **Memory:** RAM and Swap usage with historical graphing. - - **Network:** Real-time upload and download tracking. - - **Disks:** Storage usage visualization. - - **Processes:** List of running processes sorted by CPU usage. -- **Theming:** - - Includes 13 built-in color schemes (e.g., Dracula-like 'x', Madrid, Tokio, etc.). - - Cycle through themes instantly without configuration files. -- **Layouts:** - - **Dashboard:** Balanced view of all components (Default). - - **Vertical:** Stacked view, good for narrow terminals. - - **Process Focus:** Maximizes space for the process list while keeping essential stats visible. - -## Installation - -### Quick Install (macOS/Linux) - -The installer script automatically detects your distribution and installs all required dependencies (including Rust if needed). - -**Install with curl:** -```bash -curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -``` - -**Or with wget:** -```bash -wget -qO- https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -``` - -**Uninstall:** -```bash -curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -s -- --uninstall -``` - -
-Installer Options - -You can also run the installer with additional options: - -```bash -# Check dependencies without installing -./install.sh --check-deps - -# Install only dependencies (Rust, build tools) -./install.sh --install-deps - -# Show help -./install.sh --help -``` - -**Supported distributions:** Arch, Debian/Ubuntu, Fedora/RHEL, openSUSE, Alpine, and derivatives. - -
- -### Quick Install (Windows PowerShell) - -Requires [Rust (Cargo)](https://rustup.rs/) installed. Run in PowerShell: - -**Install:** -```powershell -irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex -``` - -**Uninstall:** -```powershell -irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex -``` - -### Build from Source - -1. Clone the repository: - ```bash - git clone https://github.com/xscriptor/xtop.git - cd xtop - ``` - -2. Build and run: - ```bash - cargo run --release - ``` +

+ View more previews +

-## Usage +
-### Keybindings +

Quick Install

-| Key | Action | -| --- | --- | -| `q` | Quit application | -| `t` | Next Color Theme | -| `T` | Previous Color Theme | -| `l` | Toggle Layout Mode (Dashboard -> Vertical -> Process Focus) | +

macOS / Linux

-### Modules +
curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
-1. **Header**: Shows system uptime, load average, current theme, and layout mode. -2. **CPU**: Shows usage bars for each CPU core. If sensors are available, shows the maximum CPU temperature. -3. **Memory**: Gauges for RAM and Swap usage, plus a line chart for RAM history. -4. **Network**: Total downloaded (RX) and uploaded (TX) data. -5. **Processes**: A scrolling list of the top 50 processes sorted by CPU usage. +

Windows (PowerShell)

-## Configuration +
irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
-Currently, `xtop` is zero-config. All preferences (theme, layout) can be toggled at runtime but are reset on restart. Future versions may include a config file. +

Build from Source

-## Contributing +
git clone https://github.com/xscriptor/xtop.git
+cd xtop
+cargo run --release
-Contributions are always welcome! Please read the [contribution guidelines](CONTRIBUTING.md) first. +

For detailed installation instructions, see docs/installation.md.

-## License -[MIT](LICENSE) +
-
----X--- -
\ No newline at end of file +

Quick Start

+ +

Run xtop after installation. Key controls:

+ + + + + + + + + + + + + +
KeyAction
qQuit (saves config)
?Toggle help overlay
t / TNext / previous theme
lNext layout mode
f / FToggle / cycle full-screen
/Search processes
+ +

For full usage details, see docs/usage.md.

+ +
+ +

Documentation

+ + + +
+ +
+

X

+ + + X Web + + & + + X Github Profile + + & + + Xscriptor web + \ No newline at end of file diff --git a/assets/layouts/cpu_focus.jsonc b/assets/layouts/cpu_focus.jsonc new file mode 100644 index 0000000..13e24b0 --- /dev/null +++ b/assets/layouts/cpu_focus.jsonc @@ -0,0 +1,12 @@ +{ + // CPU Focus: CPU takes 60%, processes the rest + "name": "CPU Focus", + "root": { + "direction": "vertical", + "areas": [ + { "widget": "header", "size": 3 }, + { "widget": "cpu", "size": "60%" }, + { "widget": "processes", "size": "*" } + ] + } +} diff --git a/assets/layouts/dashboard.jsonc b/assets/layouts/dashboard.jsonc new file mode 100644 index 0000000..9444014 --- /dev/null +++ b/assets/layouts/dashboard.jsonc @@ -0,0 +1,27 @@ +{ + // Dashboard: default 2-column layout + "name": "Dashboard", + "root": { + "direction": "vertical", + "areas": [ + { "widget": "header", "size": 3 }, + { + "direction": "horizontal", + "size": "45%", + "areas": [ + { "widget": "cpu", "size": "50%" }, + { + "direction": "vertical", + "size": "50%", + "areas": [ + { "widget": "memory", "size": "33%" }, + { "widget": "storage", "size": "33%" }, + { "widget": "network", "size": "34%" } + ] + } + ] + }, + { "widget": "processes", "size": "52%" } + ] + } +} diff --git a/assets/layouts/horizontal.jsonc b/assets/layouts/horizontal.jsonc new file mode 100644 index 0000000..03c39ae --- /dev/null +++ b/assets/layouts/horizontal.jsonc @@ -0,0 +1,20 @@ +{ + // Horizontal: 4 widgets side-by-side + "name": "Horizontal", + "root": { + "direction": "vertical", + "areas": [ + { "widget": "header", "size": 3 }, + { + "direction": "horizontal", + "size": "*", + "areas": [ + { "widget": "cpu", "size": "25%" }, + { "widget": "memory", "size": "25%" }, + { "widget": "storage", "size": "25%" }, + { "widget": "network", "size": "25%" } + ] + } + ] + } +} diff --git a/assets/layouts/memory_focus.jsonc b/assets/layouts/memory_focus.jsonc new file mode 100644 index 0000000..5630296 --- /dev/null +++ b/assets/layouts/memory_focus.jsonc @@ -0,0 +1,12 @@ +{ + // Memory Focus: Memory takes 60%, processes the rest + "name": "Memory Focus", + "root": { + "direction": "vertical", + "areas": [ + { "widget": "header", "size": 3 }, + { "widget": "memory", "size": "60%" }, + { "widget": "processes", "size": "*" } + ] + } +} diff --git a/assets/layouts/network_focus.jsonc b/assets/layouts/network_focus.jsonc new file mode 100644 index 0000000..d5c2b30 --- /dev/null +++ b/assets/layouts/network_focus.jsonc @@ -0,0 +1,19 @@ +{ + // Network Focus: Network + Disk I/O side by side, process list below + "name": "Network Focus", + "root": { + "direction": "vertical", + "areas": [ + { "widget": "header", "size": 3 }, + { + "direction": "horizontal", + "size": "50%", + "areas": [ + { "widget": "network", "size": "50%" }, + { "widget": "disk_io", "size": "50%" } + ] + }, + { "widget": "processes", "size": "*" } + ] + } +} diff --git a/assets/layouts/process_focus.jsonc b/assets/layouts/process_focus.jsonc new file mode 100644 index 0000000..a3417ee --- /dev/null +++ b/assets/layouts/process_focus.jsonc @@ -0,0 +1,21 @@ +{ + // Process Focus: mini stats row at top, full process list below + "name": "Process Focus", + "root": { + "direction": "vertical", + "areas": [ + { "widget": "header", "size": 3 }, + { + "direction": "horizontal", + "size": 8, + "areas": [ + { "widget": "cpu", "size": "25%" }, + { "widget": "memory", "size": "25%" }, + { "widget": "storage", "size": "25%" }, + { "widget": "network", "size": "25%" } + ] + }, + { "widget": "processes", "size": "*" } + ] + } +} diff --git a/assets/layouts/vertical.jsonc b/assets/layouts/vertical.jsonc new file mode 100644 index 0000000..864f022 --- /dev/null +++ b/assets/layouts/vertical.jsonc @@ -0,0 +1,15 @@ +{ + // Vertical: all widgets stacked top-to-bottom + "name": "Vertical", + "root": { + "direction": "vertical", + "areas": [ + { "widget": "header", "size": 3 }, + { "widget": "cpu", "size": 8 }, + { "widget": "memory", "size": 8 }, + { "widget": "storage", "size": 6 }, + { "widget": "network", "size": 5 }, + { "widget": "processes", "size": "*" } + ] + } +} diff --git a/assets/themes/berlin.jsonc b/assets/themes/berlin.jsonc new file mode 100644 index 0000000..e30c24c --- /dev/null +++ b/assets/themes/berlin.jsonc @@ -0,0 +1,9 @@ +{ + // Berlin -- Black bg, full grayscale ramp + "name": "berlin", + "palette": [ + "#000000", "#999999", "#bbbbbb", "#dddddd", "#888888", "#aaaaaa", "#cccccc", + "#cccccc", "#333333", "#bbbbbb", "#dddddd", "#ffffff", "#aaaaaa", "#cccccc", + "#eeeeee", "#ffffff" + ] +} diff --git a/assets/themes/bogota.jsonc b/assets/themes/bogota.jsonc new file mode 100644 index 0000000..274501d --- /dev/null +++ b/assets/themes/bogota.jsonc @@ -0,0 +1,9 @@ +{ + // Bogota -- Very dark brown bg, warm pastel accents + "name": "bogota", + "palette": [ + "#200b0a", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", "#47e6ff", + "#f7f1ff", "#525053", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", + "#47e6ff", "#f7f1ff" + ] +} diff --git a/assets/themes/helsinki.jsonc b/assets/themes/helsinki.jsonc new file mode 100644 index 0000000..58d4e2e --- /dev/null +++ b/assets/themes/helsinki.jsonc @@ -0,0 +1,9 @@ +{ + // Helsinki -- Light theme (white bg), earthy tones + "name": "helsinki", + "palette": [ + "#f8fafe", "#1faa9e", "#733d9a", "#2e70ad", "#b55a0f", "#3e9d21", "#bd4c3d", + "#544d40", "#b0a999", "#009e91", "#5a1f8a", "#0f5ba2", "#b23b00", "#218c00", + "#b32e1f", "#000000" + ] +} diff --git a/assets/themes/lahabana.jsonc b/assets/themes/lahabana.jsonc new file mode 100644 index 0000000..a6ddcae --- /dev/null +++ b/assets/themes/lahabana.jsonc @@ -0,0 +1,9 @@ +{ + // Lahabana -- Near-black bg, green-yellow-green palette + "name": "lahabana", + "palette": [ + "#19191a", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", "#5ad4e6", + "#f7f1ff", "#19191a", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", + "#5ad4e6", "#f7f1ff" + ] +} diff --git a/assets/themes/london.jsonc b/assets/themes/london.jsonc new file mode 100644 index 0000000..e406715 --- /dev/null +++ b/assets/themes/london.jsonc @@ -0,0 +1,9 @@ +{ + // London -- Light theme (white bg), grayscale + "name": "london", + "palette": [ + "#ffffff", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888", + "#333333", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888", + "#999999", "#aaaaaa" + ] +} diff --git a/assets/themes/madrid.jsonc b/assets/themes/madrid.jsonc new file mode 100644 index 0000000..e8f655e --- /dev/null +++ b/assets/themes/madrid.jsonc @@ -0,0 +1,9 @@ +{ + // Madrid -- Light theme (white bg), dark red accents + "name": "madrid", + "palette": [ + "#fafafa", "#990026", "#007a28", "#8a6408", "#007a9e", "#4d2699", "#007a9e", + "#1a1a1a", "#4d4d4d", "#990026", "#007a28", "#8a6408", "#007a9e", "#4d2699", + "#007a9e", "#1a1a1a" + ] +} diff --git a/assets/themes/miami.jsonc b/assets/themes/miami.jsonc new file mode 100644 index 0000000..a62a8ee --- /dev/null +++ b/assets/themes/miami.jsonc @@ -0,0 +1,9 @@ +{ + // Miami -- Black bg, synthwave neon (the default) + "name": "miami", + "palette": [ + "#000000", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", + "#f7f1ff", "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", + "#47CFFF", "#f7f1ff" + ] +} diff --git a/assets/themes/oslo.jsonc b/assets/themes/oslo.jsonc new file mode 100644 index 0000000..55e44f3 --- /dev/null +++ b/assets/themes/oslo.jsonc @@ -0,0 +1,9 @@ +{ + // Oslo -- Dark grey bg, muted professional palette + "name": "oslo", + "palette": [ + "#3f4451", "#e05561", "#8cc265", "#d18f52", "#4aa5f0", "#c162de", "#42b3c2", + "#abb2bf", "#4f5666", "#ff616e", "#a5e075", "#f0a45d", "#4dc4ff", "#de73ff", + "#4cd1e0", "#ffffff" + ] +} diff --git a/assets/themes/paris.jsonc b/assets/themes/paris.jsonc new file mode 100644 index 0000000..e933fb6 --- /dev/null +++ b/assets/themes/paris.jsonc @@ -0,0 +1,9 @@ +{ + // Paris -- Deep purple bg, pastel accents + "name": "paris", + "palette": [ + "#1a0a30", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", "#a3f3ff", + "#f7f1ff", "#c4bdff", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", + "#a3f3ff", "#f7f1ff" + ] +} diff --git a/assets/themes/praha.jsonc b/assets/themes/praha.jsonc new file mode 100644 index 0000000..8ad3e78 --- /dev/null +++ b/assets/themes/praha.jsonc @@ -0,0 +1,9 @@ +{ + // Praha -- Dark Dracula-inspired theme + "name": "praha", + "palette": [ + "#1A1A1A", "#FF5555", "#B8E6A0", "#FFE4A3", "#BD93F9", "#FF9AA2", "#8BE9FD", + "#FFFFFF", "#6272A4", "#FF6E6E", "#B8E6A0", "#FFE4A3", "#D6ACFF", "#FF9AA2", + "#A4FFFF", "#FFFFFF" + ] +} diff --git a/assets/themes/tokio.jsonc b/assets/themes/tokio.jsonc new file mode 100644 index 0000000..6337a94 --- /dev/null +++ b/assets/themes/tokio.jsonc @@ -0,0 +1,9 @@ +{ + // Tokio -- Near-black bg, warm rainbow accents + "name": "tokio", + "palette": [ + "#1c1c1d", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", + "#f7f1ff", "#1c1c1d", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", + "#5ad4e6", "#f7f1ff" + ] +} diff --git a/assets/themes/x.jsonc b/assets/themes/x.jsonc new file mode 100644 index 0000000..b441b5b --- /dev/null +++ b/assets/themes/x.jsonc @@ -0,0 +1,9 @@ +{ + // X -- Almost-black background, purple-pink accents + "name": "x", + "palette": [ + "#050505", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", + "#f7f1ff", "#0f0f0f", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", + "#5ad4e6", "#f7f1ff" + ] +} diff --git a/references.md b/colors.md similarity index 65% rename from references.md rename to colors.md index c32104b..9f6039f 100644 --- a/references.md +++ b/colors.md @@ -1,10 +1,11 @@ -# Schemes +

Colors

-## x + +

X

```json { - "color0": "#363537", + "color0": "#0a0a0a", "color1": "#fc618d", "color2": "#7bd88f", "color3": "#fce566", @@ -12,45 +13,51 @@ "color5": "#948ae3", "color6": "#5ad4e6", "color7": "#f7f1ff", - "color8": "#69676c", + "color8": "#0f0f0f", "color9": "#fc618d", "color10": "#7bd88f", "color11": "#fce566", "color12": "#fd9353", "color13": "#948ae3", "color14": "#5ad4e6", - "color15": "#f7f1ff" + "color15": "#f7f1ff", + "background": "#050505", + "foreground": "#f7f1ff" } ``` + -## madrid +

Madrid

```json { - "color0": "#333333", - "color1": "#cc0033", - "color2": "#009933", - "color3": "#b8860b", - "color4": "#0099cc", - "color5": "#6633cc", - "color6": "#0099cc", + "color0": "#fafafa", + "color1": "#990026", + "color2": "#007a28", + "color3": "#8a6408", + "color4": "#007a9e", + "color5": "#4d2699", + "color6": "#007a9e", "color7": "#1a1a1a", - "color8": "#666666", - "color9": "#cc0033", - "color10": "#009933", - "color11": "#b8860b", - "color12": "#0099cc", - "color13": "#6633cc", - "color14": "#0099cc", - "color15": "#1a1a1a" + "color8": "#4d4d4d", + "color9": "#990026", + "color10": "#007a28", + "color11": "#8a6408", + "color12": "#007a9e", + "color13": "#4d2699", + "color14": "#007a9e", + "color15": "#1a1a1a", + "background": "#fafafa", + "foreground": "#1a1a1a" } ``` + -## lahabana +

Lahabana

```json { - "color0": "#363537", + "color0": "#19191a", "color1": "#fc618d", "color2": "#7bd88f", "color3": "#e5ff9d", @@ -58,41 +65,21 @@ "color5": "#948ae3", "color6": "#5ad4e6", "color7": "#f7f1ff", - "color8": "#69676c", + "color8": "#19191a", "color9": "#fc618d", "color10": "#7bd88f", "color11": "#e5ff9d", "color12": "#fd9353", "color13": "#948ae3", "color14": "#5ad4e6", - "color15": "#f7f1ff" -} -``` - -## seul - -```json -{ - "color0": "#1b1b1bff", - "color1": "#FF4C8B", - "color2": "#7FFFD4", - "color3": "#FFD84C", - "color4": "#00FFA8", - "color5": "#D36CFF", - "color6": "#47CFFF", - "color7": "#f7f1ff", - "color8": "#69676c", - "color9": "#FF4C8B", - "color10": "#7FFFD4", - "color11": "#FFD84C", - "color12": "#00FFA8", - "color13": "#D36CFF", - "color14": "#47CFFF", - "color15": "#f7f1ff" + "color15": "#f7f1ff", + "background": "#19191a", + "foreground": "#f7f1ff" } ``` + -## miami +

Miami

```json { @@ -111,38 +98,44 @@ "color12": "#00FFA8", "color13": "#D36CFF", "color14": "#47CFFF", - "color15": "#f7f1ff" + "color15": "#f7f1ff", + "background": "#000000", + "foreground": "#f7f1ff" } ``` + -## paris +

Paris

```json { - "color0": "#222222", + "color0": "#1a0a30", "color1": "#fc618d", "color2": "#7bd88f", "color3": "#fce566", "color4": "#a3f3ff", "color5": "#c4bdff", "color6": "#a3f3ff", - "color7": "#f7f1ff", - "color8": "#525053", + "color7": "#1a0a30", + "color8": "#c4bdff", "color9": "#fc618d", "color10": "#7bd88f", "color11": "#fce566", "color12": "#a3f3ff", "color13": "#c4bdff", "color14": "#a3f3ff", - "color15": "#f7f1ff" + "color15": "#f7f1ff", + "background": "#1a0a30", + "foreground": "#f7f1ff" } ``` + -## tokio +

Tokio

```json { - "color0": "#363537", + "color0": "#1c1c1d", "color1": "#fc618d", "color2": "#7bd88f", "color3": "#fce566", @@ -150,18 +143,21 @@ "color5": "#948ae3", "color6": "#5ad4e6", "color7": "#f7f1ff", - "color8": "#69676c", + "color8": "#1c1c1d", "color9": "#fc618d", "color10": "#7bd88f", "color11": "#fce566", "color12": "#fd9353", "color13": "#948ae3", "color14": "#5ad4e6", - "color15": "#f7f1ff" + "color15": "#f7f1ff", + "background": "#1c1c1d", + "foreground": "#f7f1ff" } ``` + -## oslo +

Oslo

```json { @@ -180,22 +176,25 @@ "color12": "#4dc4ff", "color13": "#de73ff", "color14": "#4cd1e0", - "color15": "#ffffff" + "color15": "#ffffff", + "background": "#3f4451", + "foreground": "#abb2bf" } ``` + -## helsinki +

Helsinki

```json { - "color0": "#c0bbae", + "color0": "#f8fafe", "color1": "#1faa9e", "color2": "#733d9a", "color3": "#2e70ad", "color4": "#b55a0f", "color5": "#3e9d21", "color6": "#bd4c3d", - "color7": "#191919", + "color7": "#544d40", "color8": "#b0a999", "color9": "#009e91", "color10": "#5a1f8a", @@ -203,11 +202,14 @@ "color12": "#b23b00", "color13": "#218c00", "color14": "#b32e1f", - "color15": "#000000" + "color15": "#000000", + "background": "#f8fafe", + "foreground": "#544d40" } ``` + -## berlin +

Berlin

```json { @@ -226,22 +228,25 @@ "color12": "#aaaaaa", "color13": "#cccccc", "color14": "#eeeeee", - "color15": "#ffffff" + "color15": "#ffffff", + "background": "#000000", + "foreground": "#cccccc" } ``` + -## london +

London

```json { - "color0": "#000000", + "color0": "#ffffff", "color1": "#333333", "color2": "#444444", "color3": "#555555", "color4": "#666666", "color5": "#777777", "color6": "#888888", - "color7": "#999999", + "color7": "#333333", "color8": "#333333", "color9": "#444444", "color10": "#555555", @@ -249,11 +254,14 @@ "color12": "#777777", "color13": "#888888", "color14": "#999999", - "color15": "#aaaaaa" + "color15": "#aaaaaa", + "background": "#ffffff", + "foreground": "#333333" } ``` + -## praha +

Praha

```json { @@ -272,15 +280,18 @@ "color12": "#D6ACFF", "color13": "#FF9AA2", "color14": "#A4FFFF", - "color15": "#FFFFFF" + "color15": "#FFFFFF", + "background": "#1a1a1a", + "foreground": "#ffffff" } ``` + -## bogota +

Bogota

```json { - "color0": "#222222", + "color0": "#200b0a", "color1": "#fc618d", "color2": "#7bd88f", "color3": "#ffed89", @@ -295,6 +306,8 @@ "color12": "#47e6ff", "color13": "#ff9999", "color14": "#47e6ff", - "color15": "#f7f1ff" + "color15": "#f7f1ff", + "background": "#200b0a", + "foreground": "#f7f1ff" } -``` \ No newline at end of file +``` diff --git a/crates/xtop-cli/Cargo.toml b/crates/xtop-cli/Cargo.toml new file mode 100644 index 0000000..f54c1ed --- /dev/null +++ b/crates/xtop-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "xtop" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +xtop-core = { path = "../xtop-core" } +xtop-tui = { path = "../xtop-tui" } +crossterm.workspace = true +anyhow.workspace = true +serde_json.workspace = true +toml = "0.8" + +# Optional plugins (behind feature flags) +xtop-plugin-sentinel = { path = "../../plugins/xtop-plugin-sentinel", optional = true } + +[features] +default = ["plugin-sentinel"] +plugin-sentinel = ["dep:xtop-plugin-sentinel"] diff --git a/crates/xtop-cli/src/main.rs b/crates/xtop-cli/src/main.rs new file mode 100644 index 0000000..d4f9a03 --- /dev/null +++ b/crates/xtop-cli/src/main.rs @@ -0,0 +1,714 @@ +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use xtop_core::application::plugin_manager::PluginManager; +use xtop_core::application::state::{AppState, Config, InputMode, PalettePage}; +use xtop_core::domain::keybinding::Action; +use xtop_core::infrastructure::composite_provider::CompositeProvider; +use xtop_core::infrastructure::config; +use xtop_core::infrastructure::layout_loader; +use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider; +use xtop_core::infrastructure::theme_loader::load_all_themes; +use xtop_tui::render; +use xtop_tui::terminal; + +mod mcp; + +// --------------------------------------------------------------------------- +// Plugin imports (feature-gated) +// --------------------------------------------------------------------------- +#[cfg(feature = "plugin-sentinel")] +use xtop_plugin_sentinel::SentinelPlugin; + +// --------------------------------------------------------------------------- +// Config dir helper (delegated to xtop-core to avoid duplication) +// --------------------------------------------------------------------------- +fn config_dir() -> PathBuf { + xtop_core::infrastructure::config::config_dir() +} + +fn key_event_to_str(key: &KeyEvent) -> String { + let mut s = String::new(); + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl { + s.push_str("ctrl+"); + } + if key.modifiers.contains(KeyModifiers::ALT) { + s.push_str("alt+"); + } + match key.code { + KeyCode::Char(c) => { + if ctrl { + s.push(c.to_ascii_lowercase()); + } else { + s.push(c); + } + } + KeyCode::Esc => s.push_str("escape"), + KeyCode::Enter => s.push_str("enter"), + KeyCode::Backspace => s.push_str("backspace"), + KeyCode::Tab => s.push_str("tab"), + KeyCode::Up => s.push_str("up"), + KeyCode::Down => s.push_str("down"), + KeyCode::Left => s.push_str("left"), + KeyCode::Right => s.push_str("right"), + KeyCode::Delete => s.push_str("delete"), + KeyCode::Home => s.push_str("home"), + KeyCode::End => s.push_str("end"), + KeyCode::PageUp => s.push_str("pageup"), + KeyCode::PageDown => s.push_str("pagedown"), + _ => return String::new(), + } + s +} + +// Embedded default asset files (shipped with the binary) +const DEFAULT_THEMES: &[(&str, &str)] = &[ + ("x", include_str!("../../../assets/themes/x.jsonc")), + ("madrid", include_str!("../../../assets/themes/madrid.jsonc")), + ("lahabana", include_str!("../../../assets/themes/lahabana.jsonc")), + ("paris", include_str!("../../../assets/themes/paris.jsonc")), + ("tokio", include_str!("../../../assets/themes/tokio.jsonc")), + ("oslo", include_str!("../../../assets/themes/oslo.jsonc")), + ("helsinki", include_str!("../../../assets/themes/helsinki.jsonc")), + ("berlin", include_str!("../../../assets/themes/berlin.jsonc")), + ("london", include_str!("../../../assets/themes/london.jsonc")), + ("praha", include_str!("../../../assets/themes/praha.jsonc")), + ("bogota", include_str!("../../../assets/themes/bogota.jsonc")), +]; + +const DEFAULT_LAYOUTS: &[(&str, &str)] = &[ + ("dashboard", include_str!("../../../assets/layouts/dashboard.jsonc")), + ("vertical", include_str!("../../../assets/layouts/vertical.jsonc")), + ("horizontal", include_str!("../../../assets/layouts/horizontal.jsonc")), + ("cpu_focus", include_str!("../../../assets/layouts/cpu_focus.jsonc")), + ("memory_focus", include_str!("../../../assets/layouts/memory_focus.jsonc")), + ("network_focus", include_str!("../../../assets/layouts/network_focus.jsonc")), + ("process_focus", include_str!("../../../assets/layouts/process_focus.jsonc")), +]; + +fn ensure_default_assets() { + let theme_assets: &[(&str, &str)] = DEFAULT_THEMES; + let layout_assets: &[(&str, &str)] = DEFAULT_LAYOUTS; + + let dir = xtop_core::infrastructure::theme_loader::themes_dir(); + if !dir.join(".xtop_initialized").exists() { + fs::create_dir_all(&dir).ok(); + for (name, content) in theme_assets { + let path = dir.join(format!("{name}.jsonc")); + if !path.exists() { + fs::write(&path, content).ok(); + } + } + fs::write(dir.join(".xtop_initialized"), "").ok(); + } + + let dir = xtop_core::infrastructure::layout_loader::layouts_dir(); + if !dir.join(".xtop_initialized").exists() { + fs::create_dir_all(&dir).ok(); + for (name, content) in layout_assets { + let path = dir.join(format!("{name}.jsonc")); + if !path.exists() { + fs::write(&path, content).ok(); + } + } + fs::write(dir.join(".xtop_initialized"), "").ok(); + } +} + +fn save_config(state: &AppState) { + let layout_name = if state.layout_index < state.layout_defs.len() { + state.layout_defs[state.layout_index].name.clone() + } else { + String::new() + }; + let cfg = Config { + theme: state.current_theme.name.clone(), + layout_mode: state.save_layout_mode(), + layout_name, + update_interval_ms: state.update_interval_ms, + history_points: 100, + alerts: state.alerts, + keybindings: state.keybindings.clone(), + }; + let _ = config::save_config(&cfg); +} + +fn build_plugin_manager(state: &mut AppState, cfg_dir: &PathBuf) -> PluginManager { + let plugins_dir = cfg_dir.join("plugins"); + fs::create_dir_all(&plugins_dir).ok(); + let mut mgr = PluginManager::new(plugins_dir); + + // Register plugins behind feature flags + #[cfg(feature = "plugin-sentinel")] + { + let plugin = Box::new(SentinelPlugin::new()); + if let Err(e) = mgr.register(plugin, state) { + eprintln!("[xtop] failed to load sentinel plugin: {e}"); + } + } + + mgr +} + +// --------------------------------------------------------------------------- +// CLI subcommands +// --------------------------------------------------------------------------- +fn print_usage() { + eprintln!("Usage:"); + eprintln!(" xtop Start the TUI system monitor"); + eprintln!(" xtop mcp Start MCP server (stdio transport) for AI agents"); + eprintln!(" xtop plugin list List installed plugins"); + eprintln!(" xtop plugin install Install a plugin from github.com/xscriptor/xtop/plugins/"); + eprintln!(" xtop plugin install Install a plugin from a git URL"); + eprintln!(" xtop plugin scaffold Create a new plugin crate"); +} + +/// Check if a string looks like a git URL (not a simple name). +fn is_git_url(s: &str) -> bool { + s.contains("://") || s.contains("github.com") || s.contains("git@") +} + +fn cmd_plugin_list() { + let workspace_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("Cargo.toml"); + + let content = match fs::read_to_string(&workspace_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Error reading workspace Cargo.toml: {e}"); + return; + } + }; + + // Parse workspace members for plugin crates + let mut in_members = false; + let mut plugins: Vec = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("members") { + in_members = true; + continue; + } + if in_members { + if trimmed == "]" { + break; + } + let name = trimmed.trim_matches(',').trim().trim_matches('"'); + if name.starts_with("plugins/xtop-plugin-") || name.starts_with("crates/xtop-plugin-") { + plugins.push(name.to_string()); + } + } + } + + if plugins.is_empty() { + println!("No plugins installed."); + return; + } + println!("Installed plugins:"); + for p in &plugins { + println!(" {p}"); + } +} + +fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); + let workspace_toml = workspace_dir.join("Cargo.toml"); + let cli_toml = manifest_dir.join("Cargo.toml"); + let plugins_dir = workspace_dir.join("plugins"); + + let tmp = std::env::temp_dir().join("xtop-plugin-install"); + let _ = fs::remove_dir_all(&tmp); + + let repo_url: &str; + let mut plugin_subdir: String = String::new(); + + if is_git_url(name_or_url) { + // URL-based: clone the repo directly + repo_url = name_or_url; + println!("Cloning {repo_url} ..."); + let status = std::process::Command::new("git") + .args(["clone", repo_url, tmp.to_str().unwrap()]) + .status() + .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; + if !status.success() { + anyhow::bail!("git clone failed"); + } + } else { + // Name-based: look in xtop repo's plugins/ directory + repo_url = "https://github.com/xscriptor/xtop.git"; + let candidate_names = [ + format!("plugins/xtop-plugin-{name_or_url}"), + format!("plugins/{name_or_url}"), + ]; + println!("Looking for plugin '{name_or_url}' in {repo_url} ..."); + let status = std::process::Command::new("git") + .args([ + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + repo_url, + tmp.to_str().unwrap(), + ]) + .status() + .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; + if !status.success() { + anyhow::bail!("git clone failed"); + } + + // Try each candidate path + let mut found = false; + for candidate in &candidate_names { + if tmp.join(candidate).join("Cargo.toml").exists() { + plugin_subdir = candidate.clone(); + found = true; + break; + } + } + if !found { + let _ = fs::remove_dir_all(&tmp); + anyhow::bail!( + "Plugin '{name_or_url}' not found in plugins/. \ + Tried: {}", + candidate_names.join(", ") + ); + } + println!("Found plugin at {plugin_subdir}"); + } + + // --- Determine the plugin source directory --- + let plugin_src = if plugin_subdir.is_empty() { + // URL-based: cloned repo root + tmp.clone() + } else { + // Name-based: subdirectory within cloned xtop repo + tmp.join(&plugin_subdir) + }; + + // --- Read the plugin's Cargo.toml to get the package name --- + let plugin_toml_path = plugin_src.join("Cargo.toml"); + let plugin_toml_content = fs::read_to_string(&plugin_toml_path) + .map_err(|e| anyhow::anyhow!("No Cargo.toml found: {e}"))?; + let plugin_pkg: toml::Value = plugin_toml_content + .parse() + .map_err(|e| anyhow::anyhow!("Invalid Cargo.toml: {e}"))?; + + let pkg_name = plugin_pkg + .get("package") + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow::anyhow!("package.name not found in plugin Cargo.toml"))?; + + let feature_name = pkg_name.replace('-', "_"); + let plugin_dir_name = pkg_name.replace('-', "_"); + + println!("Package name: {pkg_name}"); + + // --- Copy into local plugins/ directory --- + let target_dir = plugins_dir.join(&plugin_dir_name); + if target_dir.exists() { + anyhow::bail!("Plugin '{}' already exists at plugins/{plugin_dir_name}", pkg_name); + } + fs::create_dir_all(&plugins_dir)?; + cp_recursive(&plugin_src, &target_dir)?; + + // --- Add to workspace Cargo.toml --- + let ws_content = fs::read_to_string(&workspace_toml)?; + let member_entry = format!(" \"plugins/{plugin_dir_name}\""); + if ws_content.contains(&member_entry) { + anyhow::bail!("Already in workspace"); + } + // Insert before the closing bracket of members + let sentinel_entry = " \"plugins/xtop-plugin-sentinel\","; + let new_ws = if ws_content.contains(sentinel_entry) { + ws_content.replace( + sentinel_entry, + &format!( + "{sentinel_entry}\n{member_entry}," + ), + ) + } else { + // Fallback: insert before the closing ] of members + ws_content.replacen( + "]", + &format!(" {member_entry},\n]"), + 1, + ) + }; + fs::write(&workspace_toml, &new_ws)?; + + // --- Add to xtop-cli Cargo.toml --- + let cli_content = fs::read_to_string(&cli_toml)?; + + // Build dependency path relative to crates/xtop-cli/ + let dep_path = format!("../../plugins/{plugin_dir_name}"); + let dep_line = format!( + "{pkg_name} = {{ path = \"{dep_path}\", optional = true }}" + ); + + if !cli_content.contains(&dep_line) { + // Find the last optional plugin dependency and insert after it + let marker = "# Optional plugins (behind feature flags)"; + let new_cli = cli_content.replace( + marker, + &format!("{marker}\n{dep_line}"), + ); + fs::write(&cli_toml, &new_cli)?; + } + + // Add feature flag + let feature_line = format!("{feature_name} = [\"dep:{pkg_name}\"]"); + let cli_content2 = fs::read_to_string(&cli_toml)?; + if !cli_content2.contains(&feature_line) { + let sentinel_feature = "plugin-sentinel = [\"dep:xtop-plugin-sentinel\"]"; + let new_cli2 = if cli_content2.contains(sentinel_feature) { + cli_content2.replace( + sentinel_feature, + &format!("{sentinel_feature}\n{feature_line}"), + ) + } else { + cli_content2.replacen( + "[features]", + &format!("[features]\n{feature_line}"), + 1, + ) + }; + fs::write(&cli_toml, &new_cli2)?; + } + + // --- Rebuild --- + println!("Building xtop with {pkg_name} ..."); + let build = std::process::Command::new("cargo") + .args(["build", "--release"]) + .current_dir(&workspace_dir) + .status() + .map_err(|e| anyhow::anyhow!("cargo build failed: {e}"))?; + if !build.success() { + anyhow::bail!("Build failed. Check the plugin's compatibility."); + } + + // --- Cleanup --- + let _ = fs::remove_dir_all(&tmp); + + println!(); + println!("Plugin '{pkg_name}' installed successfully."); + println!(" Location: plugins/{plugin_dir_name}"); + println!(" Feature flag: {feature_name}"); + println!(); + println!("Note: '{feature_name}' is NOT enabled by default."); + println!("To enable it, add '{feature_name}' to the 'default' feature list"); + println!("in crates/xtop-cli/Cargo.toml, then rebuild."); + + Ok(()) +} + +fn cmd_plugin_scaffold(name: &str) -> anyhow::Result<()> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); + let plugins_dir = workspace_dir.join("plugins"); + let plugin_dir = plugins_dir.join(format!("xtop-plugin-{name}")); + + if plugin_dir.exists() { + anyhow::bail!("Plugin crate already exists at {}", plugin_dir.display()); + } + + let src_dir = plugin_dir.join("src"); + fs::create_dir_all(&src_dir)?; + + // Cargo.toml (path refs go up from plugins/ to workspace root, then into crates/) + let cargo_toml = format!( + r#"[package] +name = "xtop-plugin-{name}" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "xtop plugin: {name}" + +[dependencies] +xtop-core = {{ path = "../../crates/xtop-core" }} +ratatui.workspace = true +"# + ); + fs::write(plugin_dir.join("Cargo.toml"), &cargo_toml)?; + + // lib.rs + let lib_rs = format!( + r#"use xtop_core::domain::plugin::{{Plugin, PluginCapability, PluginContext, PluginError, PluginManifest}}; + +pub struct {name_cap}Plugin; + +impl {name_cap}Plugin {{ + pub fn new() -> Self {{ + Self + }} +}} + +impl Plugin for {name_cap}Plugin {{ + fn manifest(&self) -> PluginManifest {{ + PluginManifest {{ + id: "{name}".to_string(), + name: "{name_cap}".to_string(), + version: "0.1.0".to_string(), + description: "xtop plugin: {name}".to_string(), + capabilities: vec![PluginCapability::ReadSystemInfo], + }} + }} + + fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {{ + Ok(()) + }} +}} +"#, + name = name, + name_cap = { + let mut chars = name.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } + } + ); + fs::write(src_dir.join("lib.rs"), &lib_rs)?; + + println!("Plugin scaffold created at {}", plugin_dir.display()); + println!("To register it:"); + println!(" 1. Add \"plugins/xtop-plugin-{name}\" to [workspace].members in Cargo.toml"); + println!(" 2. Add dependency + feature flag in crates/xtop-cli/Cargo.toml"); + println!(" 3. Add #[cfg(feature = \"plugin-{name}\")] import in main.rs"); + println!(" 4. Implement Plugin trait methods"); + + Ok(()) +} + +fn cp_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + if src.is_dir() { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if file_type.is_dir() { + // Skip .git directory + if entry.file_name() != ".git" { + cp_recursive(&src_path, &dst_path)?; + } + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) + } else { + fs::copy(src, dst)?; + Ok(()) + } +} + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + + // CLI subcommands + if args.len() > 1 { + match args[1].as_str() { + "mcp" => { + ensure_default_assets(); + return mcp::run_mcp_server(); + } + "plugin" => { + if args.len() < 3 { + print_usage(); + return Ok(()); + } + match args[2].as_str() { + "list" => { + cmd_plugin_list(); + return Ok(()); + } + "install" => { + if args.len() < 4 { + eprintln!("Usage: xtop plugin install "); + return Ok(()); + } + return cmd_plugin_install(&args[3]); + } + "scaffold" => { + if args.len() < 4 { + eprintln!("Usage: xtop plugin scaffold "); + return Ok(()); + } + return cmd_plugin_scaffold(&args[3]); + } + _ => { + print_usage(); + return Ok(()); + } + } + } + "--help" | "-h" => { + print_usage(); + return Ok(()); + } + _ => {} + } + } + + ensure_default_assets(); + + terminal::install_panic_hook(); + let mut terminal = terminal::init()?; + + let cfg_dir = config_dir(); + + // Build the primary (composite) provider + let sysinfo_provider = SysinfoProvider::new(); + let composite = CompositeProvider::new(Box::new(sysinfo_provider)); + + let themes = load_all_themes(); + let cfg = config::load_config(); + let mut builtin_layouts = layout_loader::builtin_layouts(); + let custom_layouts = layout_loader::load_custom_layouts(); + builtin_layouts.extend(custom_layouts); + let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts); + + // Build and register plugins + let plugin_mgr = build_plugin_manager(&mut state, &cfg_dir); + + // Collect extra data providers from plugins and inject everything into state + let extra_providers = plugin_mgr.collect_data_providers(); + state.init_plugins(plugin_mgr, extra_providers); + + let tick_rate = Duration::from_millis(state.update_interval_ms); + let mut last_tick = Instant::now(); + + loop { + terminal.draw(|f| render::render(f, &state))?; + + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_default(); + + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + let key_str = key_event_to_str(&key); + + // Give plugins first chance to consume the key + let key_str_clone = key_str.clone(); + let key_consumed = state.with_plugin_manager_mut(|mgr, this| { + mgr.handle_key(this, &key_str_clone) + }); + if key_consumed { + continue; + } + + // DEBUG: print key for diagnostics + if cfg!(debug_assertions) && !key_str.is_empty() { + eprintln!("[key] '{key_str}'"); + } + + match state.input_mode { + InputMode::Normal => { + // Direct Ctrl+P check (works regardless of keybinding config, important on macOS) + if key_str == "ctrl+p" { + state.open_palette(); + state.input_mode = InputMode::CommandPalette; + } else if let Some(action) = state.keybindings.resolve(&key_str) { + match action { + Action::Quit => { + save_config(&state); + state.quit(); + } + Action::Cancel if state.show_help => { + state.toggle_help(); + } + Action::OpenCommandPalette => { + state.open_palette(); + state.input_mode = InputMode::CommandPalette; + } + Action::KillProcess | Action::ProcessUp | Action::ProcessDown => { + state.execute_action(&action); + } + _ => { + state.execute_action(&action); + } + } + } + } + InputMode::Searching => match key.code { + KeyCode::Esc => { + state.search_query.clear(); + state.end_search(); + } + KeyCode::Enter => { + state.end_search(); + } + KeyCode::Backspace => { + state.search_pop_char(); + } + KeyCode::Char(c) => { + state.search_push_char(c); + } + _ => {} + }, + InputMode::CommandPalette => { + let is_main = state.palette.page == PalettePage::Main; + match key.code { + KeyCode::Esc => { + state.close_palette(); + } + KeyCode::Enter => { + if let Some(action) = state.palette_selected_action() { + state.execute_action(&action); + save_config(&state); + } + } + KeyCode::Down => { + state.palette_select_next(); + } + KeyCode::Up => { + state.palette_select_prev(); + } + KeyCode::Char(c) => { + state.palette.query.push(c); + state.palette_filter(); + } + KeyCode::Backspace => { + if state.palette.query.is_empty() && !is_main { + state.palette_navigate_to(PalettePage::Main); + } else { + state.palette.query.pop(); + state.palette_filter(); + } + } + _ => {} + } + }, + } + } + } + + if last_tick.elapsed() >= tick_rate { + state.on_tick(); + last_tick = Instant::now(); + } + + if state.should_quit { + break; + } + } + + // Disable plugins on shutdown + state.with_plugin_manager_mut(|mgr, this| { + mgr.disable_all(this); + }); + + terminal::restore()?; + Ok(()) +} diff --git a/crates/xtop-cli/src/mcp.rs b/crates/xtop-cli/src/mcp.rs new file mode 100644 index 0000000..1c84a27 --- /dev/null +++ b/crates/xtop-cli/src/mcp.rs @@ -0,0 +1,77 @@ +//! MCP server entry point. +//! +//! Initializes xtop state and plugins, then delegates to the Sentinel plugin's +//! MCP module (`xtop_plugin_sentinel::mcp::run_server`) which handles the +//! actual stdin/stdout MCP protocol loop. + +use std::path::PathBuf; +use xtop_core::application::plugin_manager::PluginManager; +use xtop_core::application::state::AppState; +use xtop_core::infrastructure::composite_provider::CompositeProvider; +use xtop_core::infrastructure::config; +use xtop_core::infrastructure::layout_loader; +use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider; +use xtop_core::infrastructure::theme_loader::load_all_themes; + +#[cfg(feature = "plugin-sentinel")] +use xtop_plugin_sentinel::SentinelPlugin; + +/// Run the MCP server. +/// +/// Sets up AppState + PluginManager with Sentinel, then delegates to +/// the plugin's MCP module for the protocol loop. +pub fn run_mcp_server() -> anyhow::Result<()> { + let cfg_dir = config_dir(); + let mut state = initialize_state(&cfg_dir)?; + + // Delegate to Sentinel's MCP module + #[cfg(feature = "plugin-sentinel")] + { + return xtop_plugin_sentinel::mcp::run_server(&mut state); + } + + #[cfg(not(feature = "plugin-sentinel"))] + { + eprintln!("MCP server requires the 'plugin-sentinel' feature."); + eprintln!("Rebuild with: cargo build --features plugin-sentinel"); + std::process::exit(1); + } +} + +fn config_dir() -> PathBuf { + xtop_core::infrastructure::config::config_dir() +} + +fn build_plugin_manager(state: &mut AppState, cfg_dir: &PathBuf) -> PluginManager { + let plugins_dir = cfg_dir.join("plugins"); + std::fs::create_dir_all(&plugins_dir).ok(); + let mut mgr = PluginManager::new(plugins_dir); + + #[cfg(feature = "plugin-sentinel")] + { + let plugin = Box::new(SentinelPlugin::new()); + if let Err(e) = mgr.register(plugin, state) { + eprintln!("[xtop-mcp] failed to load sentinel plugin: {e}"); + } + } + + mgr +} + +fn initialize_state(cfg_dir: &PathBuf) -> anyhow::Result { + let sysinfo_provider = SysinfoProvider::new(); + let composite = CompositeProvider::new(Box::new(sysinfo_provider)); + + let themes = load_all_themes(); + let cfg = config::load_config(); + let mut builtin_layouts = layout_loader::builtin_layouts(); + let custom_layouts = layout_loader::load_custom_layouts(); + builtin_layouts.extend(custom_layouts); + let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts); + + let plugin_mgr = build_plugin_manager(&mut state, cfg_dir); + let extra_providers = plugin_mgr.collect_data_providers(); + state.init_plugins(plugin_mgr, extra_providers); + + Ok(state) +} diff --git a/crates/xtop-core/Cargo.toml b/crates/xtop-core/Cargo.toml new file mode 100644 index 0000000..862b526 --- /dev/null +++ b/crates/xtop-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "xtop-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +sysinfo.workspace = true +serde.workspace = true +serde_json.workspace = true +ratatui.workspace = true diff --git a/crates/xtop-core/src/application/history.rs b/crates/xtop-core/src/application/history.rs new file mode 100644 index 0000000..dafd59c --- /dev/null +++ b/crates/xtop-core/src/application/history.rs @@ -0,0 +1,142 @@ +use std::collections::VecDeque; + +pub struct MetricsHistory { + pub cpu: Vec>, + pub mem: VecDeque<(f64, f64)>, + pub net_rx: VecDeque<(f64, f64)>, + pub net_tx: VecDeque<(f64, f64)>, + max_points: usize, +} + +impl MetricsHistory { + pub fn new(max_points: usize) -> Self { + Self { + cpu: Vec::new(), + mem: VecDeque::with_capacity(max_points), + net_rx: VecDeque::with_capacity(max_points), + net_tx: VecDeque::with_capacity(max_points), + max_points, + } + } + + pub fn set_max_points(&mut self, max: usize) { + self.max_points = max; + self.mem.truncate(max); + self.mem.shrink_to_fit(); + self.net_rx.truncate(max); + self.net_rx.shrink_to_fit(); + self.net_tx.truncate(max); + self.net_tx.shrink_to_fit(); + for h in &mut self.cpu { + h.truncate(max); + h.shrink_to_fit(); + } + } + + pub fn push_cpu(&mut self, cpu_id: usize, x: f64, y: f64) { + if cpu_id >= self.cpu.len() { + self.cpu + .resize(cpu_id + 1, VecDeque::with_capacity(self.max_points)); + } + let h = &mut self.cpu[cpu_id]; + if h.len() >= self.max_points { + h.pop_front(); + } + h.push_back((x, y)); + } + + pub fn push_mem(&mut self, x: f64, y: f64) { + if self.mem.len() >= self.max_points { + self.mem.pop_front(); + } + self.mem.push_back((x, y)); + } + + pub fn push_net(&mut self, x: f64, rx: f64, tx: f64) { + if self.net_rx.len() >= self.max_points { + self.net_rx.pop_front(); + self.net_tx.pop_front(); + } + self.net_rx.push_back((x, rx)); + self.net_tx.push_back((x, tx)); + } + + pub fn reset_cpu(&mut self, count: usize) { + self.cpu = vec![VecDeque::with_capacity(self.max_points); count]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_history_new() { + let h = MetricsHistory::new(100); + assert!(h.cpu.is_empty()); + assert!(h.mem.is_empty()); + assert!(h.net_rx.is_empty()); + assert!(h.net_tx.is_empty()); + } + + #[test] + fn test_history_push_cpu() { + let mut h = MetricsHistory::new(3); + h.push_cpu(0, 1.0, 50.0); + h.push_cpu(0, 2.0, 60.0); + assert_eq!(h.cpu[0].len(), 2); + h.push_cpu(0, 3.0, 70.0); + h.push_cpu(0, 4.0, 80.0); + assert_eq!(h.cpu[0].len(), 3); + assert_eq!(h.cpu[0][0], (2.0, 60.0)); + } + + #[test] + fn test_history_push_mem() { + let mut h = MetricsHistory::new(2); + h.push_mem(1.0, 30.0); + h.push_mem(2.0, 40.0); + assert_eq!(h.mem.len(), 2); + h.push_mem(3.0, 50.0); + assert_eq!(h.mem.len(), 2); + assert_eq!(h.mem[0], (2.0, 40.0)); + } + + #[test] + fn test_history_push_net() { + let mut h = MetricsHistory::new(5); + h.push_net(1.0, 100.0, 200.0); + assert_eq!(h.net_rx.len(), 1); + assert_eq!(h.net_tx[0], (1.0, 200.0)); + } + + #[test] + fn test_history_reset_cpu() { + let mut h = MetricsHistory::new(10); + h.push_cpu(0, 1.0, 50.0); + h.push_cpu(1, 2.0, 60.0); + assert_eq!(h.cpu.len(), 2); + h.reset_cpu(4); + assert_eq!(h.cpu.len(), 4); + assert!(h.cpu[0].is_empty()); + } + + #[test] + fn test_history_auto_resize_cpu() { + let mut h = MetricsHistory::new(10); + h.push_cpu(5, 1.0, 99.0); + assert_eq!(h.cpu.len(), 6); + assert_eq!(h.cpu[5][0], (1.0, 99.0)); + } + + #[test] + fn test_history_set_max_points() { + let mut h = MetricsHistory::new(10); + for i in 0..20 { + h.push_mem(i as f64, i as f64); + } + assert_eq!(h.mem.len(), 10); + h.set_max_points(5); + assert_eq!(h.mem.len(), 5); + } +} diff --git a/crates/xtop-core/src/application/mod.rs b/crates/xtop-core/src/application/mod.rs new file mode 100644 index 0000000..9e59902 --- /dev/null +++ b/crates/xtop-core/src/application/mod.rs @@ -0,0 +1,3 @@ +pub mod history; +pub mod plugin_manager; +pub mod state; diff --git a/crates/xtop-core/src/application/plugin_manager.rs b/crates/xtop-core/src/application/plugin_manager.rs new file mode 100644 index 0000000..2b02164 --- /dev/null +++ b/crates/xtop-core/src/application/plugin_manager.rs @@ -0,0 +1,188 @@ +use std::fmt::Debug; +use std::path::PathBuf; + +use crate::application::state::AppState; +use crate::domain::plugin::{ + Plugin, PluginCapability, PluginContext, PluginError, PluginManifest, WidgetRegistration, +}; +use crate::domain::system_info::SystemDataProvider; + +/// Manages the lifecycle of all loaded plugins. +/// +/// Responsibilities: +/// - Loading and enabling plugins at startup +/// - Dispatching tick, key, and command events +/// - Collecting data providers and widgets from plugins +/// - Error isolation (a failing plugin does not crash xtop) +/// - Plugin persistence directory management +pub struct PluginManager { + plugins: Vec>, + plugin_data_base: PathBuf, +} + +impl Debug for PluginManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PluginManager") + .field("count", &self.plugins.len()) + .finish() + } +} + +impl PluginManager { + /// Create a new manager with the base directory for plugin data. + /// + /// Typically: `~/.config/xtop/plugins/` + pub fn new(plugin_data_base: PathBuf) -> Self { + Self { + plugins: Vec::new(), + plugin_data_base, + } + } + + /// Register and enable a plugin. + /// + /// This calls `on_enable` on the plugin. If it fails, the plugin is not added + /// and the error is logged. + pub fn register( + &mut self, + mut plugin: Box, + state: &mut AppState, + ) -> Result<(), PluginError> { + let id = plugin.manifest().id.clone(); + let data_dir = self.plugin_data_base.join(&id); + std::fs::create_dir_all(&data_dir) + .map_err(|e| PluginError::Recoverable(format!("failed to create plugin data dir for {id}: {e}")))?; + + let capabilities = plugin.manifest().capabilities.clone(); + let mut ctx = PluginContext { + state, + plugin_data_dir: data_dir, + capabilities, + }; + + plugin.on_enable(&mut ctx)?; + + self.plugins.push(plugin); + Ok(()) + } + + fn plugin_has_capability(plugin: &Box, cap: &PluginCapability) -> bool { + plugin.manifest().capabilities.contains(cap) + } + + fn build_context<'a>( + base: &std::path::Path, + plugin: &Box, + state: &'a mut AppState, + ) -> PluginContext<'a> { + let id = plugin.manifest().id.clone(); + let capabilities = plugin.manifest().capabilities.clone(); + PluginContext { + state, + plugin_data_dir: base.join(&id), + capabilities, + } + } + + /// Call `on_tick` on every enabled plugin. + /// Errors are caught per-plugin so one failing plugin does not affect others. + pub fn tick_all(&mut self, state: &mut AppState) { + let base = self.plugin_data_base.clone(); + for plugin in &mut self.plugins { + let id = plugin.manifest().id.clone(); + let mut ctx = Self::build_context(&base, plugin, state); + if let Err(e) = plugin.on_tick(&mut ctx) { + eprintln!("[plugin:{id}] tick error: {e}"); + } + } + } + + /// Dispatch a key event to all plugins. + /// Returns `true` if any plugin consumed the event. + pub fn handle_key(&mut self, state: &mut AppState, key: &str) -> bool { + let base = self.plugin_data_base.clone(); + for plugin in &mut self.plugins { + let id = plugin.manifest().id.clone(); + let mut ctx = Self::build_context(&base, plugin, state); + match plugin.on_key(&mut ctx, key) { + Ok(true) => return true, + Err(e) => eprintln!("[plugin:{id}] key error: {e}"), + _ => {} + } + } + false + } + + /// Collect all data providers from plugins for use in CompositeProvider. + /// Only includes providers from plugins with `ReadSystemInfo` capability. + pub fn collect_data_providers(&self) -> Vec> { + let mut providers: Vec> = Vec::new(); + for plugin in &self.plugins { + if Self::plugin_has_capability(plugin, &PluginCapability::ReadSystemInfo) { + if let Some(provider) = plugin.data_provider() { + providers.push(provider); + } + } + } + providers + } + + /// Collect all widget registrations from plugins. + /// Only includes widgets from plugins with `RenderWidgets` capability. + pub fn collect_widgets(&self) -> Vec { + let mut widgets: Vec = Vec::new(); + for plugin in &self.plugins { + if Self::plugin_has_capability(plugin, &PluginCapability::RenderWidgets) { + if let Some(widget) = plugin.widget() { + widgets.push(widget); + } + } + } + widgets + } + + /// Execute a command on the plugin identified by `plugin_id`. + /// + /// Returns the plugin's response string on success. + pub fn execute( + &mut self, + state: &mut AppState, + plugin_id: &str, + action: &str, + params: &str, + ) -> Result { + let base = self.plugin_data_base.clone(); + for plugin in &mut self.plugins { + if plugin.manifest().id != plugin_id { + continue; + } + let mut ctx = Self::build_context(&base, plugin, state); + return plugin.execute(&mut ctx, action, params); + } + Err(PluginError::Recoverable(format!("plugin not found: {plugin_id}"))) + } + + /// List all loaded plugin manifests (for display / status). + pub fn manifests(&self) -> Vec { + self.plugins.iter().map(|p| p.manifest()).collect() + } + + /// Number of loaded plugins. + pub fn count(&self) -> usize { + self.plugins.len() + } + + /// Call `on_disable` on all plugins (e.g. on shutdown). + pub fn disable_all(&mut self, state: &mut AppState) { + let base = self.plugin_data_base.clone(); + for plugin in &mut self.plugins { + let id = plugin.manifest().id.clone(); + let mut ctx = Self::build_context(&base, plugin, state); + if let Err(e) = plugin.on_disable(&mut ctx) { + eprintln!("[plugin:{id}] disable error: {e}"); + } + } + } +} + + diff --git a/crates/xtop-core/src/application/state.rs b/crates/xtop-core/src/application/state.rs new file mode 100644 index 0000000..043a405 --- /dev/null +++ b/crates/xtop-core/src/application/state.rs @@ -0,0 +1,851 @@ +use crate::application::history::MetricsHistory; +use crate::application::plugin_manager::PluginManager; +use crate::domain::keybinding::{Action, Keybindings}; +use crate::domain::metrics::SystemInfo; +use crate::domain::layout::LayoutDef; +use crate::domain::metrics::SystemSnapshot; +use crate::domain::plugin::WidgetRegistration; +use crate::domain::system_info::SystemDataProvider; +use crate::domain::theme::Theme; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum LayoutMode { + Dashboard, + Vertical, + Horizontal, + CpuFocus, + MemoryFocus, + NetworkFocus, + ProcessFocus, +} + +impl LayoutMode { + pub fn next(self) -> Self { + match self { + Self::Dashboard => Self::Vertical, + Self::Vertical => Self::Horizontal, + Self::Horizontal => Self::CpuFocus, + Self::CpuFocus => Self::MemoryFocus, + Self::MemoryFocus => Self::NetworkFocus, + Self::NetworkFocus => Self::ProcessFocus, + Self::ProcessFocus => Self::Dashboard, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Dashboard => "Dashboard", + Self::Vertical => "Vertical", + Self::Horizontal => "Horizontal", + Self::CpuFocus => "CPU Focus", + Self::MemoryFocus => "Memory Focus", + Self::NetworkFocus => "Network Focus", + Self::ProcessFocus => "Process Focus", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EffectiveLayout { + Dashboard, + Compact, + Vertical, + Horizontal, + CpuFocus, + MemoryFocus, + NetworkFocus, + ProcessFocus, + Minimal, +} + +fn layout_index_from_mode(mode: LayoutMode, defs: &[LayoutDef]) -> usize { + let label = mode.label(); + defs.iter().position(|d| d.name == label).unwrap_or(0) +} + +fn mode_from_layout_index(index: usize) -> LayoutMode { + match index { + 0 => LayoutMode::Dashboard, + 1 => LayoutMode::Vertical, + 2 => LayoutMode::Horizontal, + 3 => LayoutMode::CpuFocus, + 4 => LayoutMode::MemoryFocus, + 5 => LayoutMode::NetworkFocus, + 6 => LayoutMode::ProcessFocus, + _ => LayoutMode::Dashboard, + } +} + +pub fn detect_effective_layout(width: u16, height: u16, user_mode: LayoutMode) -> EffectiveLayout { + if width < 60 || height < 14 { + return EffectiveLayout::Minimal; + } + match user_mode { + LayoutMode::Dashboard => { + if width < 80 { + EffectiveLayout::Vertical + } else if width < 100 || height < 28 { + EffectiveLayout::Compact + } else { + EffectiveLayout::Dashboard + } + } + LayoutMode::Vertical => EffectiveLayout::Vertical, + LayoutMode::Horizontal => EffectiveLayout::Horizontal, + LayoutMode::CpuFocus => EffectiveLayout::CpuFocus, + LayoutMode::MemoryFocus => EffectiveLayout::MemoryFocus, + LayoutMode::NetworkFocus => EffectiveLayout::NetworkFocus, + LayoutMode::ProcessFocus => EffectiveLayout::ProcessFocus, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum FullScreenWidget { + None, + Cpu, + Memory, + Storage, + Network, + Processes, + DiskIO, + Gpu, + Battery, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum ProcessSortBy { + Cpu, + Memory, + Pid, + Name, +} + +impl ProcessSortBy { + pub fn next(self) -> Self { + match self { + Self::Cpu => Self::Memory, + Self::Memory => Self::Pid, + Self::Pid => Self::Name, + Self::Name => Self::Cpu, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Cpu => "CPU%", + Self::Memory => "Mem", + Self::Pid => "PID", + Self::Name => "Name", + } + } +} + +impl FullScreenWidget { + pub fn next(self) -> Self { + match self { + Self::None => Self::Cpu, + Self::Cpu => Self::Memory, + Self::Memory => Self::Storage, + Self::Storage => Self::Network, + Self::Network => Self::Processes, + Self::Processes => Self::DiskIO, + Self::DiskIO => Self::Gpu, + Self::Gpu => Self::Battery, + Self::Battery => Self::None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::None => "", + Self::Cpu => "CPU", + Self::Memory => "Memory", + Self::Storage => "Storage", + Self::Network => "Network", + Self::Processes => "Processes", + Self::DiskIO => "Disk I/O", + Self::Gpu => "GPU", + Self::Battery => "Battery", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteEntry { + pub label: String, + pub action: Action, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PalettePage { + Main, + Themes, + Layouts, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteState { + pub open: bool, + pub query: String, + pub selected: usize, + pub entries: Vec, + pub filtered: Vec, + pub page: PalettePage, +} + +impl PaletteState { + pub fn filtered_entries(&self) -> Vec<&PaletteEntry> { + self.filtered.iter().map(|&i| &self.entries[i]).collect() + } + + pub fn title(&self) -> &str { + match self.page { + PalettePage::Main => "Command Palette", + PalettePage::Themes => "Select Theme", + PalettePage::Layouts => "Select Layout", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum InputMode { + Normal, + Searching, + CommandPalette, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +pub struct AlertThresholds { + pub cpu_high: f64, + pub mem_high: f64, + pub disk_high: f64, +} + +impl Default for AlertThresholds { + fn default() -> Self { + Self { + cpu_high: 90.0, + mem_high: 90.0, + disk_high: 90.0, + } + } +} + +fn default_layout_mode() -> LayoutMode { + LayoutMode::Dashboard +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Config { + pub theme: String, + #[serde(default = "default_layout_mode")] + pub layout_mode: LayoutMode, + /// Layout name for custom layouts beyond the 7 built-in LayoutMode variants. + /// If non-empty, takes precedence over `layout_mode`. + #[serde(default)] + pub layout_name: String, + pub update_interval_ms: u64, + pub history_points: usize, + pub alerts: AlertThresholds, + #[serde(default)] + pub keybindings: Keybindings, +} + +impl Default for Config { + fn default() -> Self { + Self { + theme: "x".to_string(), + layout_mode: LayoutMode::Dashboard, + layout_name: String::new(), + update_interval_ms: 1000, + history_points: 100, + alerts: AlertThresholds::default(), + keybindings: Keybindings::default(), + } + } +} + +pub struct AppState { + provider: Box, + pub history: MetricsHistory, + pub should_quit: bool, + pub layout_mode: LayoutMode, + pub layout_index: usize, + pub layout_defs: Vec, + pub current_theme: Theme, + pub themes: Vec, + pub selected_theme_index: usize, + pub tick_count: f64, + pub show_help: bool, + pub input_mode: InputMode, + pub search_query: String, + pub full_screen_widget: FullScreenWidget, + pub alerts: AlertThresholds, + pub update_interval_ms: u64, + pub config_path: String, + pub palette: PaletteState, + pub keybindings: Keybindings, + pub process_sort: ProcessSortBy, + pub process_selected: Option, + pub sys_info: SystemInfo, + pub plugin_manager: Option, + pub plugin_widgets: Vec, +} + +impl AppState { + pub fn new( + provider: Box, + themes: Vec, + config: Config, + layout_defs: Vec, + ) -> Self { + let selected_theme_index = themes + .iter() + .position(|t| t.name == config.theme) + .unwrap_or(0); + let current_theme = themes[selected_theme_index].clone(); + let layout_index = if !config.layout_name.is_empty() { + layout_defs + .iter() + .position(|l| l.name == config.layout_name) + .unwrap_or_else(|| layout_index_from_mode(config.layout_mode, &layout_defs)) + } else { + layout_index_from_mode(config.layout_mode, &layout_defs) + }; + Self { + provider, + history: MetricsHistory::new(config.history_points), + should_quit: false, + layout_mode: config.layout_mode, + layout_index, + layout_defs, + current_theme, + themes, + selected_theme_index, + tick_count: 0.0, + show_help: false, + input_mode: InputMode::Normal, + search_query: String::new(), + full_screen_widget: FullScreenWidget::None, + alerts: config.alerts, + update_interval_ms: config.update_interval_ms, + config_path: String::new(), + palette: PaletteState { + open: false, + query: String::new(), + selected: 0, + entries: Vec::new(), + filtered: Vec::new(), + page: PalettePage::Main, + }, + keybindings: config.keybindings, + process_sort: ProcessSortBy::Cpu, + process_selected: None, + sys_info: SystemInfo::default(), + plugin_manager: None, + plugin_widgets: Vec::new(), + } + } + + /// Set the plugin manager and inject extra data providers into the composite provider. + /// Called once during initialization after all plugins are registered. + pub fn init_plugins( + &mut self, + mgr: PluginManager, + extra_providers: Vec>, + ) { + if !extra_providers.is_empty() { + self.provider.add_extras(extra_providers); + } + self.plugin_manager = Some(mgr); + self.refresh_plugin_widgets(); + } + + /// Collect plugin widgets into the state for the TUI renderer. + pub fn refresh_plugin_widgets(&mut self) { + if let Some(ref mgr) = self.plugin_manager { + let registrations = mgr.collect_widgets(); + // Only accept if the plugin has RenderWidgets capability + self.plugin_widgets = registrations; + } + } + + /// Kill a process by PID. Returns true if the signal was sent. + pub fn kill_process_by_pid(&mut self, pid: u32) -> bool { + self.provider.kill_process(pid) + } + + /// Set alert thresholds. + pub fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) { + self.alerts = AlertThresholds { + cpu_high: cpu, + mem_high: mem, + disk_high: disk, + }; + } + + /// Switch to a theme by name. Returns true if found. + pub fn set_theme_by_name(&mut self, name: &str) -> bool { + if let Some(idx) = self.themes.iter().position(|t| t.name == name) { + self.selected_theme_index = idx; + self.apply_theme(); + true + } else { + false + } + } + + /// Switch to a layout by name. Returns true if found. + pub fn set_layout_by_name(&mut self, name: &str) -> bool { + if let Some(idx) = self.layout_defs.iter().position(|l| l.name == name) { + self.layout_index = idx; + self.layout_mode = self.save_layout_mode(); + self.full_screen_widget = FullScreenWidget::None; + true + } else { + false + } + } + + pub fn current_layout(&self) -> &LayoutDef { + &self.layout_defs[self.layout_index] + } + + pub fn save_layout_mode(&self) -> LayoutMode { + mode_from_layout_index(self.layout_index) + } + + /// Safely access the plugin manager with a closure. + /// Ensures the plugin manager is always restored after the operation. + /// NOTE: does NOT call refresh_plugin_widgets — the caller must do it if needed. + pub fn with_plugin_manager_mut( + &mut self, + f: impl FnOnce(&mut PluginManager, &mut Self) -> R, + ) -> R { + let mut mgr = self + .plugin_manager + .take() + .expect("PluginManager not initialized"); + let result = f(&mut mgr, self); + self.plugin_manager = Some(mgr); + result + } + + pub fn on_tick(&mut self) { + self.provider.refresh_all(); + self.tick_count += 1.0; + let info = self.provider.system_info(); + if !info.hostname.is_empty() { + self.sys_info = info; + } + let x = self.tick_count; + let snap = self.provider.snapshot(); + + if snap.cpus.len() != self.history.cpu.len() { + self.history.reset_cpu(snap.cpus.len()); + } + for (i, cpu) in snap.cpus.iter().enumerate() { + self.history.push_cpu(i, x, cpu.usage); + } + + self.history.push_mem(x, snap.memory.percent); + + let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum(); + let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum(); + self.history.push_net(x, total_rx as f64, total_tx as f64); + + // Let plugins tick + self.with_plugin_manager_mut(|mgr, this| { + mgr.tick_all(this); + }); + self.refresh_plugin_widgets(); + } + + pub fn snapshot(&self) -> SystemSnapshot { + let mut snap = self.provider.snapshot(); + snap.disk_io = self.provider.disk_io(); + snap.batteries = self.provider.batteries(); + snap.gpus = self.provider.gpu_info(); + snap.dockers = self.provider.docker_info(); + snap.sys_info = self.provider.system_info(); + snap + } + + pub fn next_theme(&mut self) { + self.selected_theme_index = (self.selected_theme_index + 1) % self.themes.len(); + self.apply_theme(); + } + + pub fn previous_theme(&mut self) { + if self.selected_theme_index == 0 { + self.selected_theme_index = self.themes.len() - 1; + } else { + self.selected_theme_index -= 1; + } + self.apply_theme(); + } + + fn apply_theme(&mut self) { + self.current_theme = self.themes[self.selected_theme_index].clone(); + } + + pub fn next_layout(&mut self) { + self.layout_index = (self.layout_index + 1) % self.layout_defs.len(); + self.layout_mode = self.save_layout_mode(); + self.full_screen_widget = FullScreenWidget::None; + } + + pub fn current_layout_name(&self) -> &str { + &self.layout_defs[self.layout_index].name + } + + pub fn toggle_fullscreen(&mut self) { + self.full_screen_widget = match self.full_screen_widget { + FullScreenWidget::None => FullScreenWidget::Cpu, + _ => FullScreenWidget::None, + }; + } + + pub fn cycle_fullscreen_widget(&mut self) { + self.full_screen_widget = self.full_screen_widget.next(); + } + + pub fn start_search(&mut self) { + self.input_mode = InputMode::Searching; + self.search_query.clear(); + } + + pub fn search_push_char(&mut self, c: char) { + self.search_query.push(c); + } + + pub fn search_pop_char(&mut self) { + self.search_query.pop(); + } + + pub fn end_search(&mut self) { + self.input_mode = InputMode::Normal; + } + + pub fn toggle_help(&mut self) { + self.show_help = !self.show_help; + } + + pub fn quit(&mut self) { + self.should_quit = true; + } + + pub fn rebuild_palette(&mut self) { + self.palette.entries.clear(); + match self.palette.page { + PalettePage::Main => { + self.palette.entries.push(PaletteEntry { + label: "Themes →".into(), + action: Action::NavigateThemes, + }); + self.palette.entries.push(PaletteEntry { + label: "Layouts →".into(), + action: Action::NavigateLayouts, + }); + self.palette.entries.push(PaletteEntry { + label: "Toggle Fullscreen".into(), + action: Action::ToggleFullscreen, + }); + self.palette.entries.push(PaletteEntry { + label: "Cycle Fullscreen Widget".into(), + action: Action::CycleFullscreen, + }); + self.palette.entries.push(PaletteEntry { + label: "Search Processes".into(), + action: Action::Search, + }); + self.palette.entries.push(PaletteEntry { + label: "Toggle Help".into(), + action: Action::ToggleHelp, + }); + self.palette.entries.push(PaletteEntry { + label: format!("Sort: {}", self.process_sort.label()), + action: Action::SortByCpu, + }); + self.palette.entries.push(PaletteEntry { + label: "Random Theme".into(), + action: Action::RandomTheme, + }); + self.palette.entries.push(PaletteEntry { + label: "Exit".into(), + action: Action::Quit, + }); + } + PalettePage::Themes => { + for (i, theme) in self.themes.iter().enumerate() { + self.palette.entries.push(PaletteEntry { + label: theme.name.clone(), + action: Action::SelectTheme(i), + }); + } + } + PalettePage::Layouts => { + for (i, layout) in self.layout_defs.iter().enumerate() { + self.palette.entries.push(PaletteEntry { + label: layout.name.clone(), + action: Action::SelectLayout(i), + }); + } + } + } + self.palette_filter(); + } + + pub fn open_palette(&mut self) { + self.palette.open = true; + self.palette.query.clear(); + self.palette.selected = 0; + self.palette.page = PalettePage::Main; + self.rebuild_palette(); + } + + pub fn palette_navigate_to(&mut self, page: PalettePage) { + self.palette.page = page; + self.palette.query.clear(); + self.palette.selected = 0; + self.rebuild_palette(); + } + + pub fn palette_filter(&mut self) { + let q = self.palette.query.to_lowercase(); + self.palette.filtered = self + .palette + .entries + .iter() + .enumerate() + .filter(|(_, e)| q.is_empty() || e.label.to_lowercase().contains(&q)) + .map(|(i, _)| i) + .collect(); + if !self.palette.filtered.is_empty() { + self.palette.selected = self.palette.selected.min(self.palette.filtered.len() - 1); + } else { + self.palette.selected = 0; + } + } + + pub fn palette_select_next(&mut self) { + if !self.palette.filtered.is_empty() { + self.palette.selected = (self.palette.selected + 1) % self.palette.filtered.len(); + } + } + + pub fn palette_select_prev(&mut self) { + if !self.palette.filtered.is_empty() { + self.palette.selected = if self.palette.selected == 0 { + self.palette.filtered.len() - 1 + } else { + self.palette.selected - 1 + }; + } + } + + pub fn process_select_next(&mut self) { + let snap = self.snapshot(); + if snap.processes.is_empty() { + return; + } + let idx = self.process_selected.unwrap_or(0); + self.process_selected = Some((idx + 1) % snap.processes.len()); + } + + pub fn process_select_prev(&mut self) { + let snap = self.snapshot(); + if snap.processes.is_empty() { + return; + } + let idx = self.process_selected.unwrap_or(0); + self.process_selected = Some(if idx == 0 { + snap.processes.len() - 1 + } else { + idx - 1 + }); + } + + pub fn cycle_sort(&mut self) { + self.process_sort = self.process_sort.next(); + self.process_selected = None; + } + + pub fn palette_selected_action(&self) -> Option { + self.palette + .filtered + .get(self.palette.selected) + .and_then(|&i| self.palette.entries.get(i)) + .map(|e| e.action.clone()) + } + + pub fn close_palette(&mut self) { + self.palette.open = false; + self.palette.page = PalettePage::Main; + self.input_mode = InputMode::Normal; + } + + pub fn execute_action(&mut self, action: &Action) { + match action { + Action::Quit => self.quit(), + Action::ToggleHelp => self.toggle_help(), + Action::NextTheme => self.next_theme(), + Action::PreviousTheme => self.previous_theme(), + Action::NextLayout => self.next_layout(), + Action::ToggleFullscreen => self.toggle_fullscreen(), + Action::CycleFullscreen => self.cycle_fullscreen_widget(), + Action::Search => self.start_search(), + Action::OpenCommandPalette => {} + Action::Cancel => { + if self.show_help { + self.toggle_help(); + } + } + Action::SelectTheme(i) => { + self.selected_theme_index = *i; + self.apply_theme(); + } + Action::SelectLayout(i) => { + self.layout_index = *i; + self.layout_mode = self.save_layout_mode(); + self.full_screen_widget = FullScreenWidget::None; + } + Action::NavigateThemes => { + self.palette_navigate_to(PalettePage::Themes); + return; + } + Action::NavigateLayouts => { + self.palette_navigate_to(PalettePage::Layouts); + return; + } + Action::KillProcess => { + if let Some(pid) = self.process_selected { + let snap = self.snapshot(); + if pid < snap.processes.len() { + let target = snap.processes[pid].pid; + self.provider.kill_process(target); + self.process_selected = None; + } + } + } + Action::ProcessUp => self.process_select_prev(), + Action::ProcessDown => self.process_select_next(), + Action::SortByPid | Action::SortByCpu | Action::SortByName | Action::SortByMem => { + self.cycle_sort(); + } + Action::RandomTheme => { + let n = self.themes.len(); + if n > 1 { + let next = (self.selected_theme_index + 7) % n; + self.selected_theme_index = next; + self.apply_theme(); + } + } + } + // Close palette after executing any action (except navigation which returns above) + if self.input_mode == InputMode::CommandPalette { + self.close_palette(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_layout_mode_next() { + assert_eq!(LayoutMode::Dashboard.next(), LayoutMode::Vertical); + assert_eq!(LayoutMode::Vertical.next(), LayoutMode::Horizontal); + assert_eq!(LayoutMode::Horizontal.next(), LayoutMode::CpuFocus); + assert_eq!(LayoutMode::CpuFocus.next(), LayoutMode::MemoryFocus); + assert_eq!(LayoutMode::MemoryFocus.next(), LayoutMode::NetworkFocus); + assert_eq!(LayoutMode::NetworkFocus.next(), LayoutMode::ProcessFocus); + assert_eq!(LayoutMode::ProcessFocus.next(), LayoutMode::Dashboard); + } + + #[test] + fn test_detect_effective_layout_large() { + assert_eq!( + detect_effective_layout(120, 40, LayoutMode::Dashboard), + EffectiveLayout::Dashboard + ); + } + + #[test] + fn test_detect_effective_layout_compact() { + assert_eq!( + detect_effective_layout(90, 30, LayoutMode::Dashboard), + EffectiveLayout::Compact + ); + } + + #[test] + fn test_detect_effective_layout_narrow() { + assert_eq!( + detect_effective_layout(70, 30, LayoutMode::Dashboard), + EffectiveLayout::Vertical + ); + } + + #[test] + fn test_detect_effective_layout_minimal() { + assert_eq!( + detect_effective_layout(50, 15, LayoutMode::Dashboard), + EffectiveLayout::Minimal + ); + } + + #[test] + fn test_detect_effective_layout_focus_respected() { + assert_eq!( + detect_effective_layout(80, 30, LayoutMode::CpuFocus), + EffectiveLayout::CpuFocus + ); + assert_eq!( + detect_effective_layout(80, 30, LayoutMode::NetworkFocus), + EffectiveLayout::NetworkFocus + ); + } + + #[test] + fn test_detect_effective_layout_focus_downgrade() { + assert_eq!( + detect_effective_layout(50, 30, LayoutMode::CpuFocus), + EffectiveLayout::Minimal + ); + } + + #[test] + fn test_fullscreen_widget_cycle() { + assert_eq!(FullScreenWidget::None.next(), FullScreenWidget::Cpu); + assert_eq!(FullScreenWidget::Battery.next(), FullScreenWidget::None); + } + + #[test] + fn test_layout_mode_label() { + assert_eq!(LayoutMode::Dashboard.label(), "Dashboard"); + assert_eq!(LayoutMode::CpuFocus.label(), "CPU Focus"); + assert_eq!(LayoutMode::Horizontal.label(), "Horizontal"); + } + + #[test] + fn test_alert_thresholds_default() { + let a = AlertThresholds::default(); + assert_eq!(a.cpu_high, 90.0); + assert_eq!(a.mem_high, 90.0); + assert_eq!(a.disk_high, 90.0); + } + + #[test] + fn test_config_default() { + let c = Config::default(); + assert_eq!(c.theme, "x"); + assert_eq!(c.layout_mode, LayoutMode::Dashboard); + assert_eq!(c.update_interval_ms, 1000); + } + + #[test] + fn test_search_operations() {} +} diff --git a/crates/xtop-core/src/domain/keybinding.rs b/crates/xtop-core/src/domain/keybinding.rs new file mode 100644 index 0000000..9b1daaf --- /dev/null +++ b/crates/xtop-core/src/domain/keybinding.rs @@ -0,0 +1,143 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Keybindings { + #[serde(default = "vec_one_q")] + pub quit: Vec, + #[serde(default = "vec_one_question")] + pub help: Vec, + #[serde(default = "vec_one_t")] + pub next_theme: Vec, + #[serde(default = "vec_one_shift_t")] + pub prev_theme: Vec, + #[serde(default = "vec_one_l")] + pub next_layout: Vec, + #[serde(default = "vec_one_f")] + pub toggle_fullscreen: Vec, + #[serde(default = "vec_one_shift_f")] + pub cycle_fullscreen: Vec, + #[serde(default = "vec_one_slash")] + pub search: Vec, + #[serde(default = "vec_one_ctrl_p")] + pub command_palette: Vec, + #[serde(default = "vec_one_escape")] + pub cancel: Vec, + #[serde(default = "vec_one_k")] + pub kill_process: Vec, + #[serde(default = "vec_one_up")] + pub process_up: Vec, + #[serde(default = "vec_one_down")] + pub process_down: Vec, + #[serde(default = "vec_one_s")] + pub cycle_sort: Vec, +} + +fn vec_one_q() -> Vec { vec!["q".into()] } +fn vec_one_question() -> Vec { vec!["?".into()] } +fn vec_one_t() -> Vec { vec!["t".into()] } +fn vec_one_shift_t() -> Vec { vec!["T".into()] } +fn vec_one_l() -> Vec { vec!["l".into()] } +fn vec_one_f() -> Vec { vec!["f".into()] } +fn vec_one_shift_f() -> Vec { vec!["F".into()] } +fn vec_one_slash() -> Vec { vec!["/".into()] } +fn vec_one_ctrl_p() -> Vec { vec!["ctrl+p".into(), "ctrl+P".into()] } +fn vec_one_escape() -> Vec { vec!["escape".into()] } +fn vec_one_k() -> Vec { vec!["k".into()] } +fn vec_one_up() -> Vec { vec!["up".into()] } +fn vec_one_down() -> Vec { vec!["down".into()] } +fn vec_one_s() -> Vec { vec!["s".into()] } + +impl Default for Keybindings { + fn default() -> Self { + Self { + quit: vec_one_q(), + help: vec_one_question(), + next_theme: vec_one_t(), + prev_theme: vec_one_shift_t(), + next_layout: vec_one_l(), + toggle_fullscreen: vec_one_f(), + cycle_fullscreen: vec_one_shift_f(), + search: vec_one_slash(), + command_palette: vec_one_ctrl_p(), + cancel: vec_one_escape(), + kill_process: vec_one_k(), + process_up: vec_one_up(), + process_down: vec_one_down(), + cycle_sort: vec_one_s(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + Quit, + ToggleHelp, + NextTheme, + PreviousTheme, + RandomTheme, + NextLayout, + ToggleFullscreen, + CycleFullscreen, + Search, + OpenCommandPalette, + Cancel, + SelectTheme(usize), + SelectLayout(usize), + NavigateThemes, + NavigateLayouts, + KillProcess, + ProcessUp, + ProcessDown, + SortByPid, + SortByName, + SortByCpu, + SortByMem, +} + +impl Keybindings { + pub fn resolve(&self, key_str: &str) -> Option { + if self.quit.contains(&key_str.to_string()) { + return Some(Action::Quit); + } + if self.help.contains(&key_str.to_string()) { + return Some(Action::ToggleHelp); + } + if self.next_theme.contains(&key_str.to_string()) { + return Some(Action::NextTheme); + } + if self.prev_theme.contains(&key_str.to_string()) { + return Some(Action::PreviousTheme); + } + if self.next_layout.contains(&key_str.to_string()) { + return Some(Action::NextLayout); + } + if self.toggle_fullscreen.contains(&key_str.to_string()) { + return Some(Action::ToggleFullscreen); + } + if self.cycle_fullscreen.contains(&key_str.to_string()) { + return Some(Action::CycleFullscreen); + } + if self.search.contains(&key_str.to_string()) { + return Some(Action::Search); + } + if self.command_palette.contains(&key_str.to_string()) { + return Some(Action::OpenCommandPalette); + } + if self.cancel.contains(&key_str.to_string()) { + return Some(Action::Cancel); + } + if self.kill_process.contains(&key_str.to_string()) { + return Some(Action::KillProcess); + } + if self.process_up.contains(&key_str.to_string()) { + return Some(Action::ProcessUp); + } + if self.process_down.contains(&key_str.to_string()) { + return Some(Action::ProcessDown); + } + if self.cycle_sort.contains(&key_str.to_string()) { + return Some(Action::SortByCpu); + } + None + } +} diff --git a/crates/xtop-core/src/domain/layout.rs b/crates/xtop-core/src/domain/layout.rs new file mode 100644 index 0000000..c6d2ba4 --- /dev/null +++ b/crates/xtop-core/src/domain/layout.rs @@ -0,0 +1,184 @@ +use serde::de::{self, MapAccess, Visitor}; +use serde::Deserialize; +use std::fmt; + +#[derive(Clone, Debug, PartialEq)] +pub enum Direction { + Horizontal, + Vertical, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LayoutConstraint { + Length(u16), + Percentage(u16), + Fill, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutArea { + pub constraint: LayoutConstraint, + pub node: LayoutNode, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LayoutNode { + Split { + direction: Direction, + areas: Vec, + }, + Widget { + name: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutDef { + pub name: String, + pub root: LayoutNode, +} + +// --------------------------------------------------------------------------- +// Deserialization helpers +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct LayoutDefRaw { + name: String, + root: LayoutAreaRaw, +} + +#[derive(Deserialize)] +struct LayoutAreaRaw { + #[serde(default)] + size: Option, + widget: Option, + direction: Option, + #[serde(default)] + areas: Option>, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum SizeRaw { + Num(u16), + Str(String), +} + +impl TryFrom for LayoutArea { + type Error = String; + + fn try_from(raw: LayoutAreaRaw) -> Result { + let constraint = match raw.size { + None => LayoutConstraint::Fill, + Some(SizeRaw::Num(n)) => LayoutConstraint::Length(n), + Some(SizeRaw::Str(s)) if s == "*" => LayoutConstraint::Fill, + Some(SizeRaw::Str(s)) if s.ends_with('%') => { + let pct = s.trim_end_matches('%').parse::().map_err(|_| { + format!("invalid percentage: {s}") + })?; + LayoutConstraint::Percentage(pct) + } + Some(SizeRaw::Str(s)) => { + return Err(format!("invalid size constraint: {s}")); + } + }; + + let node = if let Some(name) = raw.widget { + LayoutNode::Widget { name } + } else if let Some(dir) = raw.direction { + let direction = match dir.to_lowercase().as_str() { + "horizontal" => Direction::Horizontal, + "vertical" => Direction::Vertical, + _ => return Err(format!("invalid direction: {dir}")), + }; + let areas_raw = raw.areas.unwrap_or_default(); + let mut areas = Vec::with_capacity(areas_raw.len()); + for a in areas_raw { + areas.push(a.try_into()?); + } + LayoutNode::Split { direction, areas } + } else { + return Err("layout area must have 'widget' or 'direction'".into()); + }; + + Ok(LayoutArea { constraint, node }) + } +} + +impl TryFrom for LayoutDef { + type Error = String; + + fn try_from(raw: LayoutDefRaw) -> Result { + let area: LayoutArea = raw.root.try_into()?; + Ok(LayoutDef { + name: raw.name, + root: area.node, + }) + } +} + +// Custom Deserialize for LayoutDef (handles jsonc-compatible parsing) +impl<'de> Deserialize<'de> for LayoutDef { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(field_identifier, rename_all = "snake_case")] + enum Field { + Name, + Root, + } + + struct LayoutVisitor; + impl<'de> Visitor<'de> for LayoutVisitor { + type Value = LayoutDef; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("struct LayoutDef") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut raw = LayoutDefRaw { + name: String::new(), + root: LayoutAreaRaw { + size: None, + widget: None, + direction: None, + areas: None, + }, + }; + let mut found_name = false; + let mut found_root = false; + + while let Some(key) = map.next_key::()? { + match key { + Field::Name => { + raw.name = map.next_value::()?; + found_name = true; + } + Field::Root => { + raw.root = map.next_value::()?; + found_root = true; + } + } + } + + if !found_name { + return Err(de::Error::missing_field("name")); + } + if !found_root { + return Err(de::Error::missing_field("root")); + } + + LayoutDef::try_from(raw).map_err(de::Error::custom) + } + } + + deserializer.deserialize_struct("LayoutDef", &["name", "root"], LayoutVisitor) + } +} diff --git a/crates/xtop-core/src/domain/metrics.rs b/crates/xtop-core/src/domain/metrics.rs new file mode 100644 index 0000000..a6f40d4 --- /dev/null +++ b/crates/xtop-core/src/domain/metrics.rs @@ -0,0 +1,166 @@ +#![allow(clippy::manual_non_exhaustive)] + +#[derive(Debug, Clone)] +pub struct CpuInfo { + pub name: String, + pub usage: f64, + pub cpu_id: usize, + pub frequency: u64, + pub governor: String, +} + +#[derive(Debug, Clone)] +pub struct MemoryInfo { + pub total: u64, + pub used: u64, + pub available: u64, + pub free: u64, + pub percent: f64, +} + +#[derive(Debug, Clone)] +pub struct SwapInfo { + pub total: u64, + pub used: u64, + pub free: u64, + pub percent: f64, +} + +#[derive(Debug, Clone)] +pub struct DiskInfo { + pub mount_point: String, + pub total_space: u64, + pub available_space: u64, + pub used_space: u64, + pub percent: f64, + pub file_system: String, + pub mount_options: String, +} + +#[derive(Debug, Clone)] +pub struct DiskIOInfo { + pub name: String, + pub read_bytes: u64, + pub write_bytes: u64, + pub read_speed: f64, + pub write_speed: f64, +} + +#[derive(Debug, Clone)] +pub struct NetworkInfo { + pub name: String, + pub received: u64, + pub transmitted: u64, + pub rx_speed: f64, + pub tx_speed: f64, + pub ip: Vec, +} + +#[derive(Debug, Clone)] +pub struct ProcessInfo { + pub pid: u32, + pub name: String, + pub cpu_usage: f64, + pub memory: u64, + pub user_id: Option, + pub state: String, + pub cmd: String, + + // P0 -- Malicious process detection essentials + /// Full path to the executable on disk + pub exe_path: Option, + /// Parent process ID + pub parent_pid: Option, + /// Full command-line argument vector (argv) + pub cmd_full: Vec, + + // P1 -- Timing, privilege, and context + /// Process start time as epoch seconds + pub start_time: u64, + /// Seconds since process started + pub run_time: u64, + /// Effective user ID (may differ from uid on SUID binaries) + pub effective_user_id: Option, + /// Group ID + pub group_id: Option, + /// Process working directory + pub cwd: Option, + /// Number of threads + pub thread_count: u64, + + // P2 -- I/O, environment, session + /// Number of open file descriptors + pub open_files: u64, + /// Max allowed file descriptors + pub open_files_limit: u64, + /// Total bytes read from disk by this process + pub disk_total_read_bytes: u64, + /// Total bytes written to disk by this process + pub disk_total_write_bytes: u64, + /// Environment variables + pub environ: Vec, + /// Session ID + pub session_id: Option, +} + +#[derive(Debug, Clone)] +pub struct LoadAvg { + pub one: f64, + pub five: f64, + pub fifteen: f64, +} + +#[derive(Debug, Clone)] +pub struct BatteryInfo { + pub name: String, + pub percentage: f32, + pub state: String, + pub time_to_full: Option, + pub time_to_empty: Option, + pub health: f32, + pub cycle_count: Option, +} + +#[derive(Debug, Clone)] +pub struct GpuInfo { + pub name: String, + pub usage: f64, + pub temperature: f32, + pub memory_total: u64, + pub memory_used: u64, +} + +#[derive(Debug, Clone)] +pub struct DockerInfo { + pub name: String, + pub status: String, + pub cpu_usage: f64, + pub memory_usage: u64, +} + +#[derive(Debug, Clone, Default)] +pub struct SystemInfo { + pub hostname: String, + pub os_version: String, + pub kernel: String, + pub desktop_env: String, + pub shell: String, +} + +#[derive(Debug, Clone)] +pub struct SystemSnapshot { + pub cpus: Vec, + pub memory: MemoryInfo, + pub swap: SwapInfo, + pub disks: Vec, + pub networks: Vec, + pub processes: Vec, + pub load_avg: LoadAvg, + pub uptime: u64, + pub cpu_temp: f64, + pub disk_io: Vec, + pub batteries: Vec, + pub gpus: Vec, + pub dockers: Vec, + pub sys_info: SystemInfo, +} diff --git a/crates/xtop-core/src/domain/mod.rs b/crates/xtop-core/src/domain/mod.rs new file mode 100644 index 0000000..35bf2ef --- /dev/null +++ b/crates/xtop-core/src/domain/mod.rs @@ -0,0 +1,6 @@ +pub mod keybinding; +pub mod layout; +pub mod metrics; +pub mod plugin; +pub mod system_info; +pub mod theme; diff --git a/crates/xtop-core/src/domain/plugin.rs b/crates/xtop-core/src/domain/plugin.rs new file mode 100644 index 0000000..f832733 --- /dev/null +++ b/crates/xtop-core/src/domain/plugin.rs @@ -0,0 +1,216 @@ +use std::fmt::Debug; + +use crate::application::state::AppState; +use crate::domain::metrics::SystemSnapshot; +use crate::domain::system_info::SystemDataProvider; + +/// Unique identifier for a plugin capability. +/// Used for permission checking and manifest declaration. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum PluginCapability { + /// Read system metrics (CPU, memory, network, disks, processes) + ReadSystemInfo, + /// Terminate processes + KillProcesses, + /// Modify configuration (themes, layouts, alerts, interval) + ModifyConfig, + /// Register custom widgets in the TUI + RenderWidgets, + /// Anything not covered above + Custom(String), +} + +/// Static metadata about a plugin. +/// Returned by [`Plugin::manifest`]. +#[derive(Clone, Debug)] +pub struct PluginManifest { + pub id: String, + pub name: String, + pub version: String, + pub description: String, + pub capabilities: Vec, +} + +/// Error type for plugin operations. +#[derive(Debug)] +pub enum PluginError { + /// A recoverable error (e.g. invalid params, resource busy) + Recoverable(String), + /// A fatal error (plugin should be disabled) + Fatal(String), + /// Action not understood by this plugin + UnknownAction(String), +} + +impl std::fmt::Display for PluginError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Recoverable(msg) => write!(f, "{msg}"), + Self::Fatal(msg) => write!(f, "FATAL: {msg}"), + Self::UnknownAction(action) => write!(f, "unknown action: {action}"), + } + } +} + +impl std::error::Error for PluginError {} + +/// A widget that a plugin registers for rendering in the TUI. +pub struct WidgetRegistration { + pub name: String, + pub render: std::sync::Arc, +} + +impl Debug for WidgetRegistration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WidgetRegistration") + .field("name", &self.name) + .finish() + } +} + +/// Context passed to plugin lifecycle methods. +/// Provides safe, limited access to application state and plugin-specific directories. +pub struct PluginContext<'a> { + pub(crate) state: &'a mut AppState, + pub(crate) plugin_data_dir: std::path::PathBuf, + pub(crate) capabilities: Vec, +} + +impl PluginContext<'_> { + fn check_capability(&self, cap: &PluginCapability) -> Result<(), PluginError> { + if self.capabilities.contains(cap) { + Ok(()) + } else { + Err(PluginError::Recoverable(format!( + "plugin does not have required capability: {:?}", + cap + ))) + } + } + + /// Full system snapshot with all available metrics. + /// Requires `ReadSystemInfo` capability. + pub fn snapshot(&self) -> SystemSnapshot { + self.state.snapshot() + } + + /// The top N processes sorted by CPU usage. + /// Requires `ReadSystemInfo` capability. + pub fn top_processes(&self, n: usize) -> Vec { + let snap = self.snapshot(); + snap.processes.into_iter().take(n).collect() + } + + /// Kill a process by PID. Returns true if the signal was sent. + /// Requires `KillProcesses` capability. + pub fn kill_process(&mut self, pid: u32) -> Result { + self.check_capability(&PluginCapability::KillProcesses)?; + Ok(self.state.kill_process_by_pid(pid)) + } + + /// Set alert thresholds for CPU, memory, and disk. + /// Requires `ModifyConfig` capability. + pub fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) -> Result<(), PluginError> { + self.check_capability(&PluginCapability::ModifyConfig)?; + self.state.set_alert_thresholds(cpu, mem, disk); + Ok(()) + } + + /// Switch to a theme by name. Returns true if found. + /// Requires `ModifyConfig` capability. + pub fn set_theme_by_name(&mut self, name: &str) -> Result { + self.check_capability(&PluginCapability::ModifyConfig)?; + Ok(self.state.set_theme_by_name(name)) + } + + /// Switch to a layout by name. Returns true if found. + /// Requires `ModifyConfig` capability. + pub fn set_layout_by_name(&mut self, name: &str) -> Result { + self.check_capability(&PluginCapability::ModifyConfig)?; + Ok(self.state.set_layout_by_name(name)) + } + + /// Set the update interval in milliseconds. + /// Requires `ModifyConfig` capability. + pub fn set_update_interval(&mut self, ms: u64) -> Result<(), PluginError> { + self.check_capability(&PluginCapability::ModifyConfig)?; + self.state.update_interval_ms = ms; + Ok(()) + } + + /// Current system info (hostname, OS, kernel). + /// Requires `ReadSystemInfo` capability. + pub fn system_info(&self) -> crate::domain::metrics::SystemInfo { + self.state.sys_info.clone() + } + + /// Plugin-specific data directory (`~/.config/xtop/plugins//`). + pub fn data_dir(&self) -> &std::path::Path { + &self.plugin_data_dir + } + + /// Current AppState read-only snapshot for widget rendering data. + /// Requires `ReadSystemInfo` capability. + pub fn state(&self) -> &AppState { + self.state + } +} + +/// The core trait that every plugin must implement. +/// +/// All methods have default empty implementations so plugins only +/// override what they need. +pub trait Plugin: Debug + Send { + /// Static metadata about this plugin. + fn manifest(&self) -> PluginManifest; + + /// Called once when the plugin is loaded and enabled. + fn on_enable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { + Ok(()) + } + + /// Called once when the plugin is disabled or xtop shuts down. + fn on_disable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { + Ok(()) + } + + /// Called on every tick (every ~1s by default). + fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { + Ok(()) + } + + /// Called when a key is pressed. + /// Return `Ok(true)` if the plugin consumed the key event. + fn on_key( + &mut self, + _ctx: &mut PluginContext, + _key: &str, + ) -> Result { + Ok(false) + } + + /// Optionally provide additional system data. + /// The returned provider is merged into the main data stream via CompositeProvider. + fn data_provider(&self) -> Option> { + None + } + + /// Optionally register a custom widget for TUI rendering. + fn widget(&self) -> Option { + None + } + + /// Execute a named command with string parameters. + /// Used by external agents (AI, CLI, IPC) to interact with the plugin. + /// + /// Returns a JSON-like string response. + fn execute( + &mut self, + _ctx: &mut PluginContext, + _action: &str, + _params: &str, + ) -> Result { + Err(PluginError::UnknownAction(_action.to_string())) + } +} diff --git a/crates/xtop-core/src/domain/system_info.rs b/crates/xtop-core/src/domain/system_info.rs new file mode 100644 index 0000000..6f03984 --- /dev/null +++ b/crates/xtop-core/src/domain/system_info.rs @@ -0,0 +1,32 @@ +use crate::domain::metrics::*; + +pub trait SystemDataProvider: Send { + fn refresh_all(&mut self); + fn snapshot(&self) -> SystemSnapshot; + fn disk_io(&self) -> Vec { + vec![] + } + fn batteries(&self) -> Vec { + vec![] + } + fn gpu_info(&self) -> Vec { + vec![] + } + fn docker_info(&self) -> Vec { + vec![] + } + fn system_info(&self) -> SystemInfo { + SystemInfo::default() + } + fn kill_process(&self, _pid: u32) -> bool { + false + } + + /// Downcast to `Any` for internal provider composition. + fn as_any(&self) -> &dyn std::any::Any; + fn as_any_mut(&mut self) -> &mut dyn std::any::Any; + + /// Add extra data providers (used by CompositeProvider). + /// Default no-op implementation for non-composite providers. + fn add_extras(&mut self, _extras: Vec>) {} +} diff --git a/crates/xtop-core/src/domain/theme.rs b/crates/xtop-core/src/domain/theme.rs new file mode 100644 index 0000000..12db302 --- /dev/null +++ b/crates/xtop-core/src/domain/theme.rs @@ -0,0 +1,93 @@ +use serde::de::{self, Deserializer, MapAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +fn hex_to_rgb(hex: &str) -> [u8; 3] { + let hex = hex.trim_start_matches('#'); + let r = u8::from_str_radix(hex.get(0..2).unwrap_or("00"), 16).unwrap_or(0); + let g = u8::from_str_radix(hex.get(2..4).unwrap_or("00"), 16).unwrap_or(0); + let b = u8::from_str_radix(hex.get(4..6).unwrap_or("00"), 16).unwrap_or(0); + [r, g, b] +} + +#[derive(Clone, Debug, Serialize)] +pub struct Theme { + pub name: String, + pub palette: [[u8; 3]; 16], +} + +impl Theme { + pub fn bg(&self) -> &[u8; 3] { + &self.palette[0] + } + + pub fn fg(&self) -> &[u8; 3] { + &self.palette[7] + } +} + +// Custom deserializer that reads palette as [String; 16] (hex) and converts to [[u8; 3]; 16] +impl<'de> Deserialize<'de> for Theme { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(field_identifier, rename_all = "snake_case")] + enum Field { + Name, + Palette, + } + + struct ThemeVisitor; + impl<'de> Visitor<'de> for ThemeVisitor { + type Value = Theme; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct Theme") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut name = None; + let mut palette: Option<[String; 16]> = None; + + while let Some(key) = map.next_key::()? { + match key { + Field::Name => { + if name.is_some() { + return Err(de::Error::duplicate_field("name")); + } + name = Some(map.next_value::()?); + } + Field::Palette => { + if palette.is_some() { + return Err(de::Error::duplicate_field("palette")); + } + palette = Some(map.next_value::<[String; 16]>()?); + } + } + } + + let name = name.ok_or_else(|| de::Error::missing_field("name"))?; + let palette_str = + palette.ok_or_else(|| de::Error::missing_field("palette"))?; + + let mut palette = [[0u8; 3]; 16]; + for (i, hex) in palette_str.iter().enumerate() { + palette[i] = hex_to_rgb(hex); + } + + Ok(Theme { name, palette }) + } + } + + deserializer.deserialize_struct("Theme", &["name", "palette"], ThemeVisitor) + } +} + +pub fn hex_to_rgb_pub(hex: &str) -> [u8; 3] { + hex_to_rgb(hex) +} diff --git a/crates/xtop-core/src/infrastructure/composite_provider.rs b/crates/xtop-core/src/infrastructure/composite_provider.rs new file mode 100644 index 0000000..cbfe338 --- /dev/null +++ b/crates/xtop-core/src/infrastructure/composite_provider.rs @@ -0,0 +1,122 @@ +use crate::domain::metrics::*; +use crate::domain::system_info::SystemDataProvider; + +/// A `SystemDataProvider` that composes a primary provider with plugin-provided extras. +/// +/// The primary provider (usually `SysinfoProvider`) handles `refresh_all()` and `snapshot()`. +/// Extra providers (from plugins) override specific methods like `gpu_info()`, `batteries()`, +/// `docker_info()`, `disk_io()`, or `system_info()`. +/// +/// This allows plugins to inject data without modifying the primary provider. +pub struct CompositeProvider { + primary: Box, + extras: Vec>, +} + +impl CompositeProvider { + pub fn new(primary: Box) -> Self { + Self { + primary, + extras: Vec::new(), + } + } + + /// Get the first non-empty result from extras for a method, + /// falling back to a fallback closure on the primary. + fn first_non_empty( + &self, + primary_fn: impl FnOnce() -> Vec, + extra_fn: impl Fn(&dyn SystemDataProvider) -> Vec, + ) -> Vec { + let primary_val = primary_fn(); + if !primary_val.is_empty() { + return primary_val; + } + for extra in &self.extras { + let val = extra_fn(extra.as_ref()); + if !val.is_empty() { + return val; + } + } + primary_val + } +} + +impl SystemDataProvider for CompositeProvider { + fn refresh_all(&mut self) { + self.primary.refresh_all(); + for extra in &mut self.extras { + extra.refresh_all(); + } + } + + fn snapshot(&self) -> SystemSnapshot { + self.primary.snapshot() + } + + fn disk_io(&self) -> Vec { + self.first_non_empty( + || self.primary.disk_io(), + |e| e.disk_io(), + ) + } + + fn batteries(&self) -> Vec { + self.first_non_empty( + || self.primary.batteries(), + |e| e.batteries(), + ) + } + + fn gpu_info(&self) -> Vec { + self.first_non_empty( + || self.primary.gpu_info(), + |e| e.gpu_info(), + ) + } + + fn docker_info(&self) -> Vec { + self.first_non_empty( + || self.primary.docker_info(), + |e| e.docker_info(), + ) + } + + fn system_info(&self) -> SystemInfo { + let primary = self.primary.system_info(); + if !primary.hostname.is_empty() { + return primary; + } + for extra in &self.extras { + let val = extra.system_info(); + if !val.hostname.is_empty() { + return val; + } + } + primary + } + + fn kill_process(&self, pid: u32) -> bool { + if self.primary.kill_process(pid) { + return true; + } + for extra in &self.extras { + if extra.kill_process(pid) { + return true; + } + } + false + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn add_extras(&mut self, extras: Vec>) { + self.extras = extras; + } +} diff --git a/crates/xtop-core/src/infrastructure/config.rs b/crates/xtop-core/src/infrastructure/config.rs new file mode 100644 index 0000000..5eaf243 --- /dev/null +++ b/crates/xtop-core/src/infrastructure/config.rs @@ -0,0 +1,37 @@ +use crate::application::state::Config; +use std::fs; +use std::path::PathBuf; + +pub fn config_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("xtop") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".config").join("xtop") + } else { + PathBuf::from(".").join(".config").join("xtop") + } +} + +pub fn config_path() -> PathBuf { + config_dir().join("config.json") +} + +pub fn load_config() -> Config { + let path = config_path(); + if let Ok(data) = fs::read_to_string(&path) { + if let Ok(cfg) = serde_json::from_str::(&data) { + return cfg; + } + } + Config::default() +} + +pub fn save_config(config: &Config) -> Result<(), String> { + let path = config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?; + fs::write(&path, data).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/crates/xtop-core/src/infrastructure/layout_loader.rs b/crates/xtop-core/src/infrastructure/layout_loader.rs new file mode 100644 index 0000000..3a059d6 --- /dev/null +++ b/crates/xtop-core/src/infrastructure/layout_loader.rs @@ -0,0 +1,264 @@ +use crate::domain::layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode}; +use std::fs; +use std::path::Path; + +fn strip_jsonc_comments(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let chars: Vec = input.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '/' && i + 1 < chars.len() { + if chars[i + 1] == '/' { + i += 2; + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } + if chars[i + 1] == '*' { + i += 2; + while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') { + i += 1; + } + i += 2; + continue; + } + } + out.push(chars[i]); + i += 1; + } + out +} + +fn load_layout_from_file(path: &Path) -> Option { + let data = fs::read_to_string(path).ok()?; + let cleaned = strip_jsonc_comments(&data); + serde_json::from_str::(&cleaned).ok() +} + +pub fn layouts_dir() -> std::path::PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + std::path::PathBuf::from(xdg).join("xtop").join("layouts") + } else if let Ok(home) = std::env::var("HOME") { + std::path::PathBuf::from(home) + .join(".config") + .join("xtop") + .join("layouts") + } else { + std::path::PathBuf::from(".") + .join(".config") + .join("xtop") + .join("layouts") + } +} + +pub fn load_custom_layouts() -> Vec { + let dir = layouts_dir(); + if !dir.exists() { + return vec![]; + } + let mut layouts = vec![]; + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + let ext = path.extension().and_then(|e| e.to_str()); + if ext == Some("json") || ext == Some("jsonc") { + if let Some(layout) = load_layout_from_file(&path) { + layouts.push(layout); + } + } + } + } + layouts +} + +pub fn builtin_layouts() -> Vec { + vec![ + dashboard_layout(), + vertical_layout(), + horizontal_layout(), + cpu_focus_layout(), + memory_focus_layout(), + network_focus_layout(), + process_focus_layout(), + ] +} + +fn area(size: u16, node: LayoutNode) -> LayoutArea { + LayoutArea { + constraint: LayoutConstraint::Length(size), + node, + } +} + +fn pct(pct: u16, node: LayoutNode) -> LayoutArea { + LayoutArea { + constraint: LayoutConstraint::Percentage(pct), + node, + } +} + +fn fill(node: LayoutNode) -> LayoutArea { + LayoutArea { + constraint: LayoutConstraint::Fill, + node, + } +} + +fn widget(name: &str) -> LayoutNode { + LayoutNode::Widget { + name: name.to_string(), + } +} + +fn split_h(areas: Vec) -> LayoutNode { + LayoutNode::Split { + direction: Direction::Horizontal, + areas, + } +} + +fn split_v(areas: Vec) -> LayoutNode { + LayoutNode::Split { + direction: Direction::Vertical, + areas, + } +} + +fn dashboard_layout() -> LayoutDef { + LayoutDef { + name: "Dashboard".into(), + root: split_v(vec![ + area(3, widget("header")), + pct(45, split_h(vec![ + pct(50, widget("cpu")), + pct(50, split_v(vec![ + pct(33, widget("memory")), + pct(33, widget("storage")), + pct(34, widget("network")), + ])), + ])), + pct(52, widget("processes")), + ]), + } +} + +fn vertical_layout() -> LayoutDef { + LayoutDef { + name: "Vertical".into(), + root: split_v(vec![ + area(3, widget("header")), + area(8, widget("cpu")), + area(8, widget("memory")), + area(6, widget("storage")), + area(5, widget("network")), + fill(widget("processes")), + ]), + } +} + +fn horizontal_layout() -> LayoutDef { + LayoutDef { + name: "Horizontal".into(), + root: split_v(vec![ + area(3, widget("header")), + fill(split_h(vec![ + pct(25, widget("cpu")), + pct(25, widget("memory")), + pct(25, widget("storage")), + pct(25, widget("network")), + ])), + ]), + } +} + +fn cpu_focus_layout() -> LayoutDef { + LayoutDef { + name: "CPU Focus".into(), + root: split_v(vec![ + area(3, widget("header")), + pct(60, widget("cpu")), + fill(widget("processes")), + ]), + } +} + +fn memory_focus_layout() -> LayoutDef { + LayoutDef { + name: "Memory Focus".into(), + root: split_v(vec![ + area(3, widget("header")), + pct(60, widget("memory")), + fill(widget("processes")), + ]), + } +} + +fn network_focus_layout() -> LayoutDef { + LayoutDef { + name: "Network Focus".into(), + root: split_v(vec![ + area(3, widget("header")), + pct(50, split_h(vec![ + pct(50, widget("network")), + pct(50, widget("disk_io")), + ])), + fill(widget("processes")), + ]), + } +} + +fn process_focus_layout() -> LayoutDef { + LayoutDef { + name: "Process Focus".into(), + root: split_v(vec![ + area(3, widget("header")), + area(8, split_h(vec![ + pct(25, widget("cpu")), + pct(25, widget("memory")), + pct(25, widget("storage")), + pct(25, widget("network")), + ])), + fill(widget("processes")), + ]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builtin_layouts_count() { + let layouts = builtin_layouts(); + assert_eq!(layouts.len(), 7); + } + + #[test] + fn test_builtin_names() { + let layouts = builtin_layouts(); + let names: Vec<&str> = layouts.iter().map(|l| l.name.as_str()).collect(); + assert!(names.contains(&"Dashboard")); + assert!(names.contains(&"Vertical")); + assert!(names.contains(&"CPU Focus")); + assert!(names.contains(&"Process Focus")); + } + + #[test] + fn test_load_layout_from_jsonc() { + let jsonc = r#"{ + // my custom layout + "name": "test", + "root": { + "direction": "vertical", + "areas": [ + { "widget": "header", "size": 3 }, + { "widget": "cpu", "size": "*" } + ] + } + }"#; + let cleaned = strip_jsonc_comments(jsonc); + let layout: LayoutDef = serde_json::from_str(&cleaned).unwrap(); + assert_eq!(layout.name, "test"); + } +} diff --git a/crates/xtop-core/src/infrastructure/mod.rs b/crates/xtop-core/src/infrastructure/mod.rs new file mode 100644 index 0000000..d6c12a1 --- /dev/null +++ b/crates/xtop-core/src/infrastructure/mod.rs @@ -0,0 +1,5 @@ +pub mod composite_provider; +pub mod config; +pub mod layout_loader; +pub mod sysinfo_provider; +pub mod theme_loader; diff --git a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs new file mode 100644 index 0000000..ce2648e --- /dev/null +++ b/crates/xtop-core/src/infrastructure/sysinfo_provider.rs @@ -0,0 +1,622 @@ +use crate::domain::metrics::*; +use crate::domain::system_info::SystemDataProvider; +use std::collections::HashMap; +use std::time::Instant; +use sysinfo::{ + Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, Pid, ProcessRefreshKind, + RefreshKind, Signal, System, +}; + +pub const DEFAULT_MAX_PROCESSES: usize = 200; + +pub struct SysinfoProvider { + sys: System, + disks: Disks, + networks: Networks, + components: Components, + prev_disk_read: HashMap, + prev_disk_write: HashMap, + prev_net_rx: HashMap, + prev_net_tx: HashMap, + last_refresh: Instant, + cached_sys_info: SystemInfo, + max_processes: usize, +} + +impl Default for SysinfoProvider { + fn default() -> Self { + Self::new() + } +} + +impl SysinfoProvider { + pub fn new() -> Self { + let sys = System::new_with_specifics( + RefreshKind::nothing() + .with_cpu(CpuRefreshKind::everything()) + .with_memory(MemoryRefreshKind::everything()) + .with_processes(ProcessRefreshKind::everything()), + ); + let info = SystemInfo { + hostname: System::host_name().unwrap_or_default(), + os_version: System::long_os_version().unwrap_or_default(), + kernel: System::kernel_version().unwrap_or_default(), + desktop_env: std::env::var("XDG_CURRENT_DESKTOP") + .or_else(|_| std::env::var("DESKTOP_SESSION")) + .unwrap_or_default(), + shell: std::env::var("SHELL") + .or_else(|_| std::env::var("ComSpec")) + .unwrap_or_default(), + }; + Self { + sys, + disks: Disks::new_with_refreshed_list(), + networks: Networks::new_with_refreshed_list(), + components: Components::new_with_refreshed_list(), + prev_disk_read: HashMap::new(), + prev_disk_write: HashMap::new(), + prev_net_rx: HashMap::new(), + prev_net_tx: HashMap::new(), + last_refresh: Instant::now(), + cached_sys_info: info, + max_processes: DEFAULT_MAX_PROCESSES, + } + } +} + +impl SystemDataProvider for SysinfoProvider { + fn refresh_all(&mut self) { + self.sys.refresh_all(); + self.disks.refresh(true); + self.networks.refresh(true); + self.components.refresh(true); + } + + fn snapshot(&self) -> SystemSnapshot { + let cpus: Vec = self + .sys + .cpus() + .iter() + .enumerate() + .map(|(i, c)| CpuInfo { + name: c.name().to_string(), + usage: c.cpu_usage() as f64, + cpu_id: i, + frequency: c.frequency(), + governor: read_cpu_governor(i), + }) + .collect(); + + let total_mem = self.sys.total_memory(); + let used_mem = self.sys.used_memory(); + let memory = MemoryInfo { + total: total_mem, + used: used_mem, + available: self.sys.available_memory(), + free: self.sys.free_memory(), + percent: if total_mem > 0 { + (used_mem as f64 / total_mem as f64) * 100.0 + } else { + 0.0 + }, + }; + + let total_swap = self.sys.total_swap(); + let used_swap = self.sys.used_swap(); + let swap = SwapInfo { + total: total_swap, + used: used_swap, + free: self.sys.free_swap(), + percent: if total_swap > 0 { + (used_swap as f64 / total_swap as f64) * 100.0 + } else { + 0.0 + }, + }; + + let mount_info = read_mount_options(); + + let disks: Vec = self + .disks + .iter() + .map(|d| { + let mp = d.mount_point().to_string_lossy().to_string(); + let total = d.total_space(); + let available = d.available_space(); + let used = total - available; + let opts = mount_info.get(&mp).cloned().unwrap_or_default(); + DiskInfo { + mount_point: mp, + total_space: total, + available_space: available, + used_space: used, + percent: if total > 0 { + (used as f64 / total as f64) * 100.0 + } else { + 0.0 + }, + file_system: d.file_system().to_string_lossy().to_string(), + mount_options: opts, + } + }) + .collect(); + + let iface_ips = read_interface_ips(); + + let networks: Vec = self + .networks + .iter() + .map(|(name, n)| { + let rx = n.received(); + let tx = n.transmitted(); + let rx_speed = if let Some(prev) = self.prev_net_rx.get(name) { + let elapsed = self.last_refresh.elapsed().as_secs_f64(); + if elapsed > 0.0 { + (rx.saturating_sub(*prev)) as f64 / elapsed + } else { + 0.0 + } + } else { + 0.0 + }; + let tx_speed = if let Some(prev) = self.prev_net_tx.get(name) { + let elapsed = self.last_refresh.elapsed().as_secs_f64(); + if elapsed > 0.0 { + (tx.saturating_sub(*prev)) as f64 / elapsed + } else { + 0.0 + } + } else { + 0.0 + }; + NetworkInfo { + name: name.clone(), + received: rx, + transmitted: tx, + rx_speed, + tx_speed, + ip: iface_ips.get(name).cloned().unwrap_or_default(), + } + }) + .collect(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut procs: Vec = self + .sys + .processes() + .iter() + .map(|(pid, p)| { + let start = p.start_time(); + let run = if start > 0 { now.saturating_sub(start) } else { 0 }; + ProcessInfo { + pid: pid.as_u32(), + name: p.name().to_string_lossy().to_string(), + cpu_usage: p.cpu_usage() as f64, + memory: p.memory(), + user_id: p.user_id().map(|u| u.to_string()), + state: format!("{:?}", p.status()), + cmd: p.cmd().first().map(|c| c.to_string_lossy().to_string()).unwrap_or_default(), + + // P0 + exe_path: p.exe().map(|e| e.to_string_lossy().to_string()), + parent_pid: p.parent().map(|ppid| ppid.as_u32()), + cmd_full: p.cmd().iter().map(|c| c.to_string_lossy().to_string()).collect(), + + // P1 + start_time: start, + run_time: run, + effective_user_id: p.effective_user_id().map(|u| u.to_string()), + group_id: p.group_id().map(|g| g.to_string()), + cwd: p.cwd().map(|c| c.to_string_lossy().to_string()), + thread_count: read_thread_count(p.pid()), + + // P2 + open_files: p.open_files().unwrap_or(0) as u64, + open_files_limit: p.open_files_limit().unwrap_or(0) as u64, + disk_total_read_bytes: p.disk_usage().total_read_bytes, + disk_total_write_bytes: p.disk_usage().total_written_bytes, + environ: p.environ().iter().map(|e| e.to_string_lossy().to_string()).collect(), + session_id: p.session_id().map(|s| s.as_u32()), + } + }) + .collect(); + procs.sort_by(|a, b| { + b.cpu_usage + .partial_cmp(&a.cpu_usage) + .unwrap_or(std::cmp::Ordering::Equal) + }); + procs.truncate(self.max_processes); + + let mut max_temp = 0.0f32; + for component in &self.components { + if let Some(temp) = component.temperature() { + if temp > max_temp { + max_temp = temp; + } + } + } + + let load = System::load_average(); + + SystemSnapshot { + cpu_temp: max_temp as f64, + cpus, + memory, + swap, + disks, + networks, + processes: procs, + load_avg: LoadAvg { + one: load.one, + five: load.five, + fifteen: load.fifteen, + }, + uptime: System::uptime(), + disk_io: self.disk_io_inner(), + batteries: read_batteries(), + gpus: read_gpu_info(), + dockers: vec![], + sys_info: SystemInfo::default(), + } + } + + fn disk_io(&self) -> Vec { + self.disk_io_inner() + } + + fn system_info(&self) -> SystemInfo { + self.cached_sys_info.clone() + } + + fn kill_process(&self, pid: u32) -> bool { + if let Some(process) = self.sys.process(Pid::from(pid as usize)) { + // Only allow killing processes owned by the same user (safety check) + let current_uid = self + .sys + .process(sysinfo::get_current_pid().unwrap_or(Pid::from(0))) + .and_then(|p| p.user_id()); + let target_uid = process.user_id(); + match (current_uid, target_uid) { + (Some(current), Some(target)) if current == target => { + process.kill_with(Signal::Term).unwrap_or(false) + } + (Some(_), Some(_)) => false, + _ => process.kill_with(Signal::Term).unwrap_or(false), + } + } else { + false + } + } + + fn batteries(&self) -> Vec { + read_batteries() + } + + fn gpu_info(&self) -> Vec { + read_gpu_info() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } +} + +impl SysinfoProvider { + fn disk_io_inner(&self) -> Vec { + self.disks + .iter() + .map(|d| { + let name = d.mount_point().to_string_lossy().to_string(); + let usage = d.usage(); + let read_bytes = usage.read_bytes; + let write_bytes = usage.written_bytes; + let (read_speed, write_speed) = if let (Some(prev_r), Some(prev_w)) = ( + self.prev_disk_read.get(&name), + self.prev_disk_write.get(&name), + ) { + let elapsed = self.last_refresh.elapsed().as_secs_f64(); + if elapsed > 0.0 { + ( + (read_bytes.saturating_sub(*prev_r)) as f64 / elapsed, + (write_bytes.saturating_sub(*prev_w)) as f64 / elapsed, + ) + } else { + (0.0, 0.0) + } + } else { + (0.0, 0.0) + }; + DiskIOInfo { + name, + read_bytes, + write_bytes, + read_speed, + write_speed, + } + }) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Platform-specific helpers with graceful fallbacks +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +fn read_cpu_governor(_cpu_id: usize) -> String { + std::fs::read_to_string(format!("/sys/devices/system/cpu/cpu{_cpu_id}/cpufreq/scaling_governor")) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +#[cfg(not(target_os = "linux"))] +fn read_cpu_governor(_cpu_id: usize) -> String { + String::new() +} + +#[cfg(target_os = "linux")] +fn read_mount_options() -> HashMap { + let mut map = HashMap::new(); + if let Ok(content) = std::fs::read_to_string("/proc/self/mountinfo") { + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + // Format: id parent_id maj:min root mount_point options ... + if parts.len() >= 6 { + let mount_point = parts[4].to_string(); + let opts = parts[5].to_string(); + map.insert(mount_point, opts); + } + } + } + map +} + +#[cfg(not(target_os = "linux"))] +fn read_mount_options() -> HashMap { + HashMap::new() +} + +#[cfg(target_os = "linux")] +fn read_interface_ips() -> HashMap> { + let mut map: HashMap> = HashMap::new(); + // Parse /proc/net/if_inet6 for IPv6 addresses + if let Ok(content) = std::fs::read_to_string("/proc/net/if_inet6") { + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 5 { + let addr_hex = parts[0]; + let iface = parts[4].to_string(); + if addr_hex.len() == 32 { + let ip: String = (0..8) + .map(|i| { + let start = i * 4; + let group = &addr_hex[start..start + 4]; + let trimmed = group.trim_start_matches('0'); + let val = u16::from_str_radix(if trimmed.is_empty() { "0" } else { trimmed }, 16).unwrap_or(0); + format!("{:x}", val) + }) + .collect::>() + .join(":"); + map.entry(iface).or_default().push(ip); + } + } + } + } + // Parse /proc/net/fib_trie for IPv4 (fallback) + map +} + +#[cfg(not(target_os = "linux"))] +fn read_interface_ips() -> HashMap> { + HashMap::new() +} + +#[cfg(target_os = "linux")] +fn read_batteries() -> Vec { + let mut batteries = Vec::new(); + let power_supply = std::path::Path::new("/sys/class/power_supply"); + if !power_supply.exists() { + return batteries; + } + if let Ok(entries) = std::fs::read_dir(power_supply) { + for entry in entries.flatten() { + let name = match entry.file_name().to_str() { + Some(n) if n.starts_with("BAT") => n.to_string(), + _ => continue, + }; + let base = entry.path(); + let capacity = std::fs::read_to_string(base.join("capacity")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0.0); + let state = std::fs::read_to_string(base.join("status")) + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + let charge_full = std::fs::read_to_string(base.join("charge_full")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let charge_now = std::fs::read_to_string(base.join("charge_now")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let cycles = std::fs::read_to_string(base.join("cycle_count")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + + // time至full/empty estimation from power + let power_now = std::fs::read_to_string(base.join("power_now")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let charge_full_design = std::fs::read_to_string(base.join("charge_full_design")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(1); + + let health = if charge_full_design > 0 { + (charge_full.unwrap_or(1) as f32 / charge_full_design as f32) * 100.0 + } else { + 100.0 + }; + + let (time_to_full, time_to_empty) = if power_now != 0 && power_now.abs() > 0 { + if state == "Charging" { + let remaining = charge_full.unwrap_or(0).saturating_sub(charge_now.unwrap_or(0)); + let secs = (remaining as f64 / power_now.abs() as f64 * 3600.0) as u64; + (Some(secs), None) + } else if state == "Discharging" { + let secs = (charge_now.unwrap_or(0) as f64 / power_now.abs() as f64 * 3600.0) as u64; + (None, Some(secs)) + } else { + (None, None) + } + } else { + (None, None) + }; + + batteries.push(BatteryInfo { + name, + percentage: capacity, + state, + time_to_full, + time_to_empty, + health, + cycle_count: cycles, + }); + } + } + batteries +} + +#[cfg(not(target_os = "linux"))] +fn read_batteries() -> Vec { + // sysinfo's battery info is limited. On macOS we'd need IOKit. + // On Windows we'd need WMI. For now, return empty. + Vec::new() +} + +fn read_gpu_info() -> Vec { + let mut gpus = Vec::new(); + // Try nvidia-smi first (cross-platform, works on Linux and Windows with NVIDIA drivers) + if let Ok(output) = std::process::Command::new("nvidia-smi") + .args([ + "--query-gpu=name,utilization.gpu,memory.total,memory.used,temperature.gpu", + "--format=csv,noheader,nounits", + ]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() >= 5 { + let name = parts[0].to_string(); + let usage = parts[1].parse::().unwrap_or(0.0); + let mem_total = parts[2].parse::().unwrap_or(0) * 1024 * 1024; + let mem_used = parts[3].parse::().unwrap_or(0) * 1024 * 1024; + let temp = parts[4].parse::().unwrap_or(0.0); + gpus.push(GpuInfo { + name, + usage, + temperature: temp, + memory_total: mem_total, + memory_used: mem_used, + }); + } + } + } + } + + // Fallback: try reading from /sys/class/drm/ on Linux + #[cfg(target_os = "linux")] + if gpus.is_empty() { + if let Ok(entries) = std::fs::read_dir("/sys/class/drm/") { + for entry in entries.flatten() { + let fname = entry.file_name().to_string_lossy().to_string(); + if fname.starts_with("card") && !fname.contains('-') { + let base = entry.path(); + let dev = base.join("device"); + let gpu_name = std::fs::read_to_string(dev.join("product_name")).ok() + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| fname.clone()); + let mem_total = std::fs::read_to_string(dev.join("mem_info_vram_total")).ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let mem_used = std::fs::read_to_string(dev.join("mem_info_vram_used")).ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let temp = find_hwmon_temp(&base.join("device"), "gpu").unwrap_or(0.0); + gpus.push(GpuInfo { + name: gpu_name, + usage: 0.0, + temperature: temp, + memory_total: mem_total, + memory_used: mem_used, + }); + } + } + } + } + + gpus +} + +#[cfg(target_os = "linux")] +fn find_hwmon_temp(device_path: &std::path::Path, label_filter: &str) -> Option { + let hwmon = device_path.join("hwmon"); + if hwmon.exists() { + if let Ok(entries) = std::fs::read_dir(&hwmon) { + for entry in entries.flatten() { + let hwmon_dir = entry.path(); + if let Ok(labels) = std::fs::read_to_string(hwmon_dir.join("temp1_label")) { + if labels.trim().to_lowercase().contains(label_filter) { + if let Ok(input) = std::fs::read_to_string(hwmon_dir.join("temp1_input")) { + if let Ok(millideg) = input.trim().parse::() { + return Some(millideg / 1000.0); + } + } + } + } + } + } + } + None +} + +#[cfg(not(target_os = "linux"))] +#[allow(dead_code)] +fn find_hwmon_temp(_device_path: &std::path::Path, _label_filter: &str) -> Option { + None +} + +// --------------------------------------------------------------------------- +// Thread count helper +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +fn read_thread_count(pid: sysinfo::Pid) -> u64 { + use std::fs; + let path = format!("/proc/{}/status", pid); + if let Ok(content) = fs::read_to_string(&path) { + for line in content.lines() { + if let Some(rest) = line.strip_prefix("Threads:\t") { + return rest.trim().parse::().unwrap_or(0); + } + } + } + 0 +} + +#[cfg(not(target_os = "linux"))] +fn read_thread_count(_pid: sysinfo::Pid) -> u64 { + // Fallback: use tasks count from sysinfo (available on some platforms) + 0 +} diff --git a/crates/xtop-core/src/infrastructure/theme_loader.rs b/crates/xtop-core/src/infrastructure/theme_loader.rs new file mode 100644 index 0000000..b769948 --- /dev/null +++ b/crates/xtop-core/src/infrastructure/theme_loader.rs @@ -0,0 +1,157 @@ +use crate::domain::theme::Theme; +use std::fs; +use std::path::Path; + +fn make_theme(name: &str, colors: [&str; 16]) -> Theme { + let mut palette = [[0u8; 3]; 16]; + for (i, h) in colors.iter().enumerate() { + palette[i] = crate::domain::theme::hex_to_rgb_pub(h); + } + Theme { + name: name.to_string(), + palette, + } +} + +fn default_theme() -> Theme { + make_theme( + "x", + [ + "#050505", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", + "#f7f1ff", "#0f0f0f", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", + "#5ad4e6", "#f7f1ff", + ], + ) +} + +fn strip_jsonc_comments(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let chars: Vec = input.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '/' && i + 1 < chars.len() { + if chars[i + 1] == '/' { + i += 2; + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } + if chars[i + 1] == '*' { + i += 2; + while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') { + i += 1; + } + i += 2; + continue; + } + } + out.push(chars[i]); + i += 1; + } + out +} + +fn load_theme_from_file(path: &Path) -> Option { + let data = fs::read_to_string(path).ok()?; + let cleaned = strip_jsonc_comments(&data); + serde_json::from_str::(&cleaned).ok() +} + +fn load_themes_from_dir(dir: &Path) -> Vec { + if !dir.exists() { + return vec![]; + } + let mut themes = vec![]; + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + let ext = path.extension().and_then(|e| e.to_str()); + if ext == Some("json") || ext == Some("jsonc") { + if let Some(theme) = load_theme_from_file(&path) { + themes.push(theme); + } + } + } + } + themes +} + +pub fn themes_dir() -> std::path::PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + std::path::PathBuf::from(xdg).join("xtop").join("themes") + } else if let Ok(home) = std::env::var("HOME") { + std::path::PathBuf::from(home) + .join(".config") + .join("xtop") + .join("themes") + } else { + std::path::PathBuf::from(".") + .join(".config") + .join("xtop") + .join("themes") + } +} + +pub fn load_all_themes() -> Vec { + let mut themes = vec![default_theme()]; + + let user_dir = themes_dir(); + let custom = load_themes_from_dir(&user_dir); + for t in custom { + if !themes.iter().any(|existing| existing.name == t.name) { + themes.push(t); + } + } + + themes +} + +pub fn builtin_themes() -> Vec { + vec![default_theme()] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_theme() { + let t = default_theme(); + assert_eq!(t.name, "x"); + assert_eq!(t.bg(), &[5, 5, 5]); + } + + #[test] + fn test_builtin_themes_count() { + let themes = builtin_themes(); + assert_eq!(themes.len(), 1); + } + + #[test] + fn test_strip_line_comment() { + let input = "{\"name\": \"test\" // comment\n}"; + let result = strip_jsonc_comments(input); + assert_eq!(result, "{\"name\": \"test\" \n}"); + } + + #[test] + fn test_strip_block_comment() { + let input = "{\"name\": /* comment */ \"test\"}"; + let result = strip_jsonc_comments(input); + assert_eq!(result, "{\"name\": \"test\"}"); + } + + #[test] + fn test_strip_no_comments() { + let input = "{\"name\": \"test\"}"; + let result = strip_jsonc_comments(input); + assert_eq!(result, "{\"name\": \"test\"}"); + } + + #[test] + fn test_hex_to_rgb() { + let result = crate::domain::theme::hex_to_rgb_pub("#ff0000"); + assert_eq!(result, [255, 0, 0]); + } +} diff --git a/crates/xtop-core/src/lib.rs b/crates/xtop-core/src/lib.rs new file mode 100644 index 0000000..50d0795 --- /dev/null +++ b/crates/xtop-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod application; +pub mod domain; +pub mod infrastructure; diff --git a/crates/xtop-tui/Cargo.toml b/crates/xtop-tui/Cargo.toml new file mode 100644 index 0000000..dc9be70 --- /dev/null +++ b/crates/xtop-tui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "xtop-tui" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +xtop-core = { path = "../xtop-core" } +ratatui.workspace = true +crossterm.workspace = true diff --git a/crates/xtop-tui/src/color.rs b/crates/xtop-tui/src/color.rs new file mode 100644 index 0000000..88d9d55 --- /dev/null +++ b/crates/xtop-tui/src/color.rs @@ -0,0 +1,20 @@ +use ratatui::prelude::Color; + +pub fn to_color(c: &[u8; 3]) -> Color { + Color::Rgb(c[0], c[1], c[2]) +} + +/// Returns a palette index for gauge color based on percentage: +/// - <50% → green (2) +/// - 50–79% → yellow (3) +/// - ≥80% → red (1) +pub fn gauge_gradient(pct: f64, alert_at: f64) -> usize { + if pct >= alert_at { + 1 + } else if pct >= 50.0 { + 3 + } else { + 2 + } +} + diff --git a/crates/xtop-tui/src/format.rs b/crates/xtop-tui/src/format.rs new file mode 100644 index 0000000..d3036f2 --- /dev/null +++ b/crates/xtop-tui/src/format.rs @@ -0,0 +1,81 @@ +pub fn format_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut size = bytes as f64; + let mut unit_idx = 0; + while size >= 1024.0 && unit_idx < UNITS.len() - 1 { + size /= 1024.0; + unit_idx += 1; + } + format!("{:.2} {}", size, UNITS[unit_idx]) +} + +pub fn format_uptime(secs: u64) -> String { + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + format!("{}d {}h {}m {}s", days, hours, minutes, seconds) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_bytes_bytes() { + assert_eq!(format_bytes(0), "0.00 B"); + assert_eq!(format_bytes(500), "500.00 B"); + } + + #[test] + fn test_format_bytes_kb() { + assert_eq!(format_bytes(1024), "1.00 KB"); + assert_eq!(format_bytes(2048), "2.00 KB"); + assert_eq!(format_bytes(1536), "1.50 KB"); + } + + #[test] + fn test_format_bytes_mb() { + assert_eq!(format_bytes(1048576), "1.00 MB"); + assert_eq!(format_bytes(3145728), "3.00 MB"); + } + + #[test] + fn test_format_bytes_gb() { + assert_eq!(format_bytes(1073741824), "1.00 GB"); + let two_gb = 2u64 * 1024 * 1024 * 1024; + assert_eq!(format_bytes(two_gb), "2.00 GB"); + } + + #[test] + fn test_format_bytes_tb() { + let one_tb = 1024u64 * 1024 * 1024 * 1024; + assert_eq!(format_bytes(one_tb), "1.00 TB"); + } + + #[test] + fn test_format_uptime_zero() { + assert_eq!(format_uptime(0), "0d 0h 0m 0s"); + } + + #[test] + fn test_format_uptime_full() { + let secs = 1 + 60 * 2 + 3600 * 3 + 86400 * 4; // 4d 3h 2m 1s + assert_eq!(format_uptime(secs), "4d 3h 2m 1s"); + } + + #[test] + fn test_format_uptime_seconds_only() { + assert_eq!(format_uptime(59), "0d 0h 0m 59s"); + } + + #[test] + fn test_format_uptime_exact_hour() { + assert_eq!(format_uptime(3600), "0d 1h 0m 0s"); + } + + #[test] + fn test_format_uptime_exact_day() { + assert_eq!(format_uptime(86400), "1d 0h 0m 0s"); + } +} diff --git a/crates/xtop-tui/src/lib.rs b/crates/xtop-tui/src/lib.rs new file mode 100644 index 0000000..db59d20 --- /dev/null +++ b/crates/xtop-tui/src/lib.rs @@ -0,0 +1,4 @@ +pub mod color; +pub mod format; +pub mod render; +pub mod terminal; diff --git a/crates/xtop-tui/src/render/battery.rs b/crates/xtop-tui/src/render/battery.rs new file mode 100644 index 0000000..ae4fa51 --- /dev/null +++ b/crates/xtop-tui/src/render/battery.rs @@ -0,0 +1,61 @@ +use crate::color::to_color; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; +use ratatui::Frame; +use xtop_core::application::state::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + + let block = Block::default() + .title("Battery") + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(area); + f.render_widget(block, area); + + let snap = state.snapshot(); + if snap.batteries.is_empty() { + let msg = Paragraph::new("No battery data available") + .style(Style::default().fg(fg)) + .wrap(Wrap { trim: true }); + f.render_widget(msg, inner); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Length(3); snap.batteries.len()]) + .split(inner); + + for (i, bat) in snap.batteries.iter().enumerate() { + if i >= chunks.len() { + break; + } + let time_info = match (bat.time_to_full, bat.time_to_empty) { + (Some(t), _) if bat.state == "Charging" => { + format!(" {}m to full", t / 60) + } + (_, Some(t)) if bat.state == "Discharging" => { + format!(" {}m remaining", t / 60) + } + _ => String::new(), + }; + let label = format!( + "{} {:>3.0}% {} {}", + bat.name, bat.percentage, bat.state, time_info, + ); + let gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[2])) + .bg(bg), + ) + .percent(bat.percentage as u16) + .label(label); + f.render_widget(gauge, chunks[i]); + } +} diff --git a/crates/xtop-tui/src/render/cpu.rs b/crates/xtop-tui/src/render/cpu.rs new file mode 100644 index 0000000..b1944cc --- /dev/null +++ b/crates/xtop-tui/src/render/cpu.rs @@ -0,0 +1,143 @@ +use crate::color::{gauge_gradient, to_color}; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType}; +use ratatui::Frame; +use xtop_core::application::state::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + let snap = state.snapshot(); + + let title = if snap.cpu_temp > 0.0 { + format!("CPU (Max: {:.1}°C)", snap.cpu_temp) + } else { + "CPU".to_string() + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(area); + f.render_widget(block, area); + + if snap.cpus.is_empty() { + return; + } + + let count = snap.cpus.len(); + let cols = if inner.width > 40 { 2 } else { 1 }; + let col_constraints = if cols == 2 { + vec![Constraint::Percentage(50); 2] + } else { + vec![Constraint::Percentage(100)] + }; + let col_areas = Layout::default() + .direction(Direction::Horizontal) + .constraints(col_constraints) + .split(inner); + + let per_col = count.div_ceil(cols); + let chart_avail = inner.height > per_col as u16 + 4; + + // Render all core gauges + for (col_idx, col_area) in col_areas.iter().enumerate() { + let start = col_idx * per_col; + let end = (start + per_col).min(count); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Length(1); end - start]) + .split(*col_area); + + for (i, row_area) in rows.iter().enumerate() { + let cpu_idx = start + i; + if cpu_idx >= count { + break; + } + let cpu = &snap.cpus[cpu_idx]; + let usage = cpu.usage; + let color_idx = if usage > state.alerts.cpu_high { + 1 + } else { + gauge_gradient(usage, state.alerts.cpu_high) + }; + let label = format!("CPU{:<2} {:>3.0}%", cpu.cpu_id, usage); + let gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[color_idx])) + .bg(bg), + ) + .percent(usage as u16) + .label(label); + f.render_widget(gauge, *row_area); + } + } + + // Aggregate CPU chart below gauges + if chart_avail { + let gauge_height = per_col as u16; // min height for gauges in one column + let chart_area = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(gauge_height), Constraint::Min(0)]) + .split(inner) + .last() + .copied() + .unwrap_or(inner); + + let max_len = state.history.cpu.iter().map(|h| h.len()).max().unwrap_or(0); + if max_len > 1 { + let mut avg: Vec<(f64, f64)> = Vec::new(); + for tick in 0..max_len { + let mut sum = 0.0; + let mut n = 0; + for core_hist in &state.history.cpu { + if tick < core_hist.len() { + sum += core_hist[tick].1; + n += 1; + } + } + if n > 0 { + let x = state.history.cpu[0] + .get(tick) + .map(|&(x, _)| x) + .unwrap_or(0.0); + avg.push((x, sum / n as f64)); + } + } + + let datasets = vec![Dataset::default() + .name("CPU Avg") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(to_color(&state.current_theme.palette[1]))) + .data(&avg)]; + + let x_min = avg.first().map(|&(x, _)| x).unwrap_or(0.0); + let x_max = avg.last().map(|&(x, _)| x).unwrap_or(100.0); + let x_max = x_max.max(x_min + 1.0); + + let chart = Chart::new(datasets) + .block(Block::default().borders(Borders::TOP)) + .x_axis( + Axis::default() + .bounds([x_min, x_max]) + .labels(vec![Span::raw("")]), + ) + .y_axis( + Axis::default() + .bounds([0.0, 100.0]) + .labels(vec![ + Span::raw("0%"), + Span::raw("50%"), + Span::raw("100%"), + ]), + ); + f.render_widget(chart, chart_area); + } + } +} diff --git a/crates/xtop-tui/src/render/disk_io.rs b/crates/xtop-tui/src/render/disk_io.rs new file mode 100644 index 0000000..5dbcdc1 --- /dev/null +++ b/crates/xtop-tui/src/render/disk_io.rs @@ -0,0 +1,80 @@ +use crate::color::to_color; +use crate::format::format_bytes; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; +use ratatui::Frame; +use xtop_core::application::state::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + + let block = Block::default() + .title("Disk I/O") + .borders(Borders::ALL) + .border_set(border::PLAIN) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(area); + f.render_widget(block, area); + + let snap = state.snapshot(); + if snap.disk_io.is_empty() { + let msg = Paragraph::new("No disk I/O data") + .style(Style::default().fg(fg)) + .wrap(Wrap { trim: true }); + f.render_widget(msg, inner); + return; + } + + // Find max speed for proportional gauge + let max_speed = snap + .disk_io + .iter() + .map(|d| d.read_speed.max(d.write_speed)) + .fold(0.0_f64, f64::max) + .max(1.0); + + let per_disk = 3.min(inner.height / snap.disk_io.len().max(1) as u16); + let per_disk = per_disk.max(2); + let constraints = vec![Constraint::Length(per_disk); snap.disk_io.len()]; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(inner); + + for (i, d) in snap.disk_io.iter().enumerate() { + if i >= chunks.len() { + break; + } + let read_speed = format_bytes(d.read_speed as u64); + let write_speed = format_bytes(d.write_speed as u64); + + let gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[4])) + .bg(bg), + ) + .percent((d.read_speed / max_speed * 100.0) as u16) + .label(format!(" {} R: {}/s", d.name, read_speed)); + f.render_widget(gauge, chunks[i]); + + // Draw write speed as a second line if there's room + if per_disk >= 3 { + let sub = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(chunks[i]); + let write_gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[5])) + .bg(bg), + ) + .percent((d.write_speed / max_speed * 100.0) as u16) + .label(format!(" W: {}/s Tot R: {} Tot W: {}", write_speed, format_bytes(d.read_bytes), format_bytes(d.write_bytes))); + f.render_widget(write_gauge, sub[1]); + } + } +} diff --git a/crates/xtop-tui/src/render/gpu.rs b/crates/xtop-tui/src/render/gpu.rs new file mode 100644 index 0000000..5aafe69 --- /dev/null +++ b/crates/xtop-tui/src/render/gpu.rs @@ -0,0 +1,57 @@ +use crate::color::to_color; +use crate::format::format_bytes; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; +use ratatui::Frame; +use xtop_core::application::state::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + + let block = Block::default() + .title("GPU") + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(area); + f.render_widget(block, area); + + let snap = state.snapshot(); + if snap.gpus.is_empty() { + let msg = Paragraph::new("No GPU data available") + .style(Style::default().fg(fg)) + .wrap(Wrap { trim: true }); + f.render_widget(msg, inner); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Length(3); snap.gpus.len()]) + .split(inner); + + for (i, gpu) in snap.gpus.iter().enumerate() { + if i >= chunks.len() { + break; + } + let label = format!( + "{} {:>3.0}% Mem: {} / {} Temp: {:.1}°C", + gpu.name, + gpu.usage, + format_bytes(gpu.memory_used), + format_bytes(gpu.memory_total), + gpu.temperature, + ); + let gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[5])) + .bg(bg), + ) + .percent(gpu.usage as u16) + .label(label); + f.render_widget(gauge, chunks[i]); + } +} diff --git a/crates/xtop-tui/src/render/header.rs b/crates/xtop-tui/src/render/header.rs new file mode 100644 index 0000000..959682f --- /dev/null +++ b/crates/xtop-tui/src/render/header.rs @@ -0,0 +1,71 @@ +use crate::color::to_color; +use crate::format::format_uptime; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; +use xtop_core::application::state::{AppState, FullScreenWidget, InputMode}; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + + let snap = state.snapshot(); + let load = snap.load_avg; + let uptime = snap.uptime; + + let mode_str = state.current_layout_name(); + + let mut extras = String::new(); + if state.full_screen_widget != FullScreenWidget::None { + extras.push_str(&format!(" [Full: {}]", state.full_screen_widget.label())); + } + if state.input_mode == InputMode::Searching { + extras.push_str(" [/] Search"); + } + + let host = &state.sys_info.hostname; + + let wide = area.width >= 80; + let text: Vec = if wide { + vec![Line::from(format!( + "{} | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2}{}", + if host.is_empty() { "xtop".to_string() } else { host.clone() }, + state.current_theme.name, + mode_str, + format_uptime(uptime), + load.one, + load.five, + load.fifteen, + extras, + ))] + } else { + let host_part = if host.is_empty() { + mode_str.to_string() + } else { + format!("{} | {}", host, mode_str) + }; + vec![ + Line::from(format!( + "{} | Uptime: {}", + host_part, + format_uptime(uptime), + )), + Line::from(format!( + "Load: {:.2} {:.2} {:.2}{}", + load.one, load.five, load.fifteen, extras, + )), + ] + }; + + let p = Paragraph::new(text) + .style(Style::default().fg(fg).bg(bg)) + .block( + Block::default() + .borders(Borders::ALL) + .border_set(border::PLAIN) + .title("System Info"), + ) + .wrap(Wrap { trim: true }); + f.render_widget(p, area); +} diff --git a/crates/xtop-tui/src/render/help.rs b/crates/xtop-tui/src/render/help.rs new file mode 100644 index 0000000..aba850e --- /dev/null +++ b/crates/xtop-tui/src/render/help.rs @@ -0,0 +1,54 @@ +use crate::color::to_color; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; +use xtop_core::application::state::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + + let text = vec![ + Line::from(""), + Line::from(vec![Span::styled( + " Keybindings", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(" ─────────────────────────────────────────────"), + Line::from(" q Quit application"), + Line::from(" ? Toggle this help screen"), + Line::from(""), + Line::from(" t Next color theme"), + Line::from(" T Previous color theme"), + Line::from(" l Next layout mode"), + Line::from(" f Toggle fullscreen widget"), + Line::from(" F Cycle fullscreen widget"), + Line::from(""), + Line::from(" / Search/filter processes"), + Line::from(" Esc Cancel search / close help"), + Line::from(""), + Line::from(" Layout modes:"), + Line::from(format!(" Current: {}", state.current_layout_name())), + Line::from(" Dashboard | Vertical | Horizontal | CPU Focus"), + Line::from(" Memory Focus | Network Focus | Process Focus"), + Line::from(" + custom layouts from ~/.config/xtop/layouts/"), + Line::from(""), + Line::from(" ─────────────────────────────────────────────"), + Line::from(""), + Line::from(" https://github.com/xscriptor/xtop"), + Line::from(""), + ]; + + let block = Block::default() + .title("Help") + .borders(Borders::ALL) + .border_set(border::DOUBLE) + .style(Style::default().fg(fg).bg(bg)); + let p = Paragraph::new(text) + .block(block) + .style(Style::default().fg(fg).bg(bg)) + .wrap(Wrap { trim: false }); + f.render_widget(p, area); +} diff --git a/crates/xtop-tui/src/render/layout_engine.rs b/crates/xtop-tui/src/render/layout_engine.rs new file mode 100644 index 0000000..1113bb0 --- /dev/null +++ b/crates/xtop-tui/src/render/layout_engine.rs @@ -0,0 +1,88 @@ +use crate::render::{battery, cpu, disk_io, gpu, header, memory, network, processes, storage}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::Frame; +use std::collections::HashMap; +use std::sync::Arc; +use xtop_core::application::state::AppState; +use xtop_core::domain::layout::{Direction, LayoutArea, LayoutDef, LayoutNode}; + +/// A widget renderer: a callable that draws a widget onto the terminal. +pub type WidgetFn = Arc; + +/// Create the default built-in widget map. +pub fn default_widgets() -> HashMap<&'static str, WidgetFn> { + let mut m: HashMap<&'static str, WidgetFn> = HashMap::new(); + m.insert("header", Arc::new(header::render)); + m.insert("cpu", Arc::new(cpu::render)); + m.insert("memory", Arc::new(memory::render)); + m.insert("storage", Arc::new(storage::render)); + m.insert("network", Arc::new(network::render)); + m.insert("processes", Arc::new(processes::render)); + m.insert("disk_io", Arc::new(disk_io::render)); + m.insert("battery", Arc::new(battery::render)); + m.insert("gpu", Arc::new(gpu::render)); + m +} + +/// Render a layout definition within a given area. +/// +/// `widgets` is the built-in registry. `plugin_widgets` is an optional +/// extension from plugins. Plugin widgets take precedence over built-ins. +pub fn render_layout( + f: &mut Frame, + state: &AppState, + area: Rect, + def: &LayoutDef, + widgets: &HashMap<&'static str, WidgetFn>, + plugin_widgets: &HashMap, +) { + render_node(f, state, area, &def.root, widgets, plugin_widgets); +} + +fn render_node( + f: &mut Frame, + state: &AppState, + area: Rect, + node: &LayoutNode, + widgets: &HashMap<&'static str, WidgetFn>, + plugin_widgets: &HashMap, +) { + match node { + LayoutNode::Widget { name } => { + // Plugin widgets take precedence + if let Some(render_fn) = plugin_widgets.get(name) { + render_fn(f, state, area); + } else if let Some(render_fn) = widgets.get(name.as_str()) { + render_fn(f, state, area); + } + // Unknown widgets are silently ignored (backward-compatible) + } + LayoutNode::Split { direction, areas } => { + if areas.is_empty() { + return; + } + let dir = match direction { + Direction::Horizontal => ratatui::prelude::Direction::Horizontal, + Direction::Vertical => ratatui::prelude::Direction::Vertical, + }; + let constraints: Vec = areas.iter().map(to_ratatui_constraint).collect(); + let chunks = Layout::default() + .direction(dir) + .constraints(constraints) + .split(area); + for (i, chunk) in chunks.iter().enumerate() { + if i < areas.len() { + render_node(f, state, *chunk, &areas[i].node, widgets, plugin_widgets); + } + } + } + } +} + +fn to_ratatui_constraint(area: &LayoutArea) -> Constraint { + match area.constraint { + xtop_core::domain::layout::LayoutConstraint::Length(n) => Constraint::Length(n), + xtop_core::domain::layout::LayoutConstraint::Percentage(p) => Constraint::Percentage(p), + xtop_core::domain::layout::LayoutConstraint::Fill => Constraint::Fill(1), + } +} diff --git a/crates/xtop-tui/src/render/memory.rs b/crates/xtop-tui/src/render/memory.rs new file mode 100644 index 0000000..918ef47 --- /dev/null +++ b/crates/xtop-tui/src/render/memory.rs @@ -0,0 +1,136 @@ +use crate::color::{gauge_gradient, to_color}; +use crate::format::format_bytes; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType}; +use ratatui::Frame; +use xtop_core::application::state::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + let snap = state.snapshot(); + + let mem_alert = snap.memory.percent > state.alerts.mem_high; + let mem_color_idx = if mem_alert { 1 } else { gauge_gradient(snap.memory.percent, state.alerts.mem_high) }; + + let mut title = "Memory".to_string(); + if mem_alert { + title = format!("Memory ⚠ {:.0}%", snap.memory.percent); + } + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(area); + f.render_widget(block, area); + + let has_chart_area = inner.height > 7; + if has_chart_area { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(inner); + + render_ram_gauge(f, state, chunks[0], &snap, bg, mem_color_idx); + render_swap_gauge(f, state, chunks[1], &snap, bg); + render_chart(f, state, chunks[2], bg); + } else { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(3)]) + .split(inner); + render_ram_gauge(f, state, chunks[0], &snap, bg, mem_color_idx); + render_swap_gauge(f, state, chunks[1], &snap, bg); + } +} + +fn render_ram_gauge( + f: &mut Frame, + state: &AppState, + area: Rect, + snap: &xtop_core::domain::metrics::SystemSnapshot, + bg: Color, + color_idx: usize, +) { + let mem_pct = snap.memory.percent as u16; + let label = format!( + "RAM: {} / {} ({:>3.0}%)", + format_bytes(snap.memory.used), + format_bytes(snap.memory.total), + snap.memory.percent, + ); + let gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[color_idx])) + .bg(bg), + ) + .percent(mem_pct) + .label(label); + f.render_widget(gauge, area); +} + +fn render_swap_gauge( + f: &mut Frame, + state: &AppState, + area: Rect, + snap: &xtop_core::domain::metrics::SystemSnapshot, + bg: Color, +) { + let swap_pct = snap.swap.percent as u16; + let color_idx = gauge_gradient(snap.swap.percent, state.alerts.mem_high); + let label = format!( + "SWP: {} / {} ({:>3.0}%)", + format_bytes(snap.swap.used), + format_bytes(snap.swap.total), + snap.swap.percent, + ); + let gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[color_idx])) + .bg(bg), + ) + .percent(swap_pct) + .label(label); + f.render_widget(gauge, area); +} + +fn render_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) { + let mem_data: Vec<(f64, f64)> = state.history.mem.iter().copied().collect(); + if mem_data.is_empty() { + return; + } + + let datasets = vec![Dataset::default() + .name("RAM Usage") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(to_color(&state.current_theme.palette[2]))) + .data(&mem_data)]; + + let x_min = mem_data.first().map(|&(x, _)| x).unwrap_or(0.0); + let x_max = mem_data.last().map(|&(x, _)| x).unwrap_or(100.0); + let x_max = x_max.max(x_min + 1.0); + + let chart = Chart::new(datasets) + .block(Block::default().borders(Borders::TOP)) + .x_axis( + Axis::default() + .bounds([x_min, x_max]) + .labels(vec![Span::raw("")]), + ) + .y_axis( + Axis::default() + .bounds([0.0, 100.0]) + .labels(vec![Span::raw("0%"), Span::raw("50%"), Span::raw("100%")]), + ); + f.render_widget(chart, area); +} diff --git a/crates/xtop-tui/src/render/mod.rs b/crates/xtop-tui/src/render/mod.rs new file mode 100644 index 0000000..f98841c --- /dev/null +++ b/crates/xtop-tui/src/render/mod.rs @@ -0,0 +1,176 @@ +mod battery; +mod cpu; +mod disk_io; +mod gpu; +mod header; +mod help; +mod memory; +mod network; +mod palette; +mod processes; +mod storage; + +mod layout_engine; + +use crate::color::to_color; +use layout_engine::{default_widgets, render_layout, WidgetFn}; +use ratatui::prelude::*; +use ratatui::Frame; +use std::collections::HashMap; +use std::sync::OnceLock; +use xtop_core::application::state::{ + detect_effective_layout, AppState, EffectiveLayout, FullScreenWidget, InputMode, +}; + +/// Built-in widgets (lazily initialized). +fn widgets() -> &'static HashMap<&'static str, WidgetFn> { + static WIDGETS: OnceLock> = OnceLock::new(); + WIDGETS.get_or_init(default_widgets) +} + +/// Build a plugin widget lookup map from AppState. +fn plugin_widgets(state: &AppState) -> HashMap { + let mut map: HashMap = HashMap::new(); + for reg in &state.plugin_widgets { + map.insert(reg.name.clone(), reg.render.clone()); + } + map +} + +pub fn render(f: &mut Frame, state: &AppState) { + let area = f.area(); + + if area.width < 40 || area.height < 8 { + render_too_small(f, state, area); + return; + } + + if state.show_help { + help::render(f, state, area); + return; + } + + if state.full_screen_widget != FullScreenWidget::None { + render_fullscreen(f, state, area); + return; + } + + let mode = detect_effective_layout(area.width, area.height, state.layout_mode); + let pw = plugin_widgets(state); + + if mode == EffectiveLayout::Minimal { + render_minimal(f, state, area); + } else { + let def = state.current_layout(); + render_layout(f, state, area, def, widgets(), &pw); + } + + if state.input_mode == InputMode::Searching { + render_search_overlay(f, state, area); + } else if state.input_mode == InputMode::CommandPalette { + palette::render(f, state, area); + } +} + +fn render_too_small(f: &mut Frame, state: &AppState, area: Rect) { + use ratatui::widgets::Paragraph; + let fg = to_color(state.current_theme.fg()); + let text = Paragraph::new("Terminal too small\nMinimum: 40x8").style(Style::default().fg(fg)); + f.render_widget(text, area); +} + +fn render_search_overlay(f: &mut Frame, state: &AppState, area: Rect) { + use ratatui::widgets::{Block, Borders, Paragraph}; + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + let search_text = format!("/{}_", state.search_query); + let overlay = Paragraph::new(search_text) + .style(Style::default().fg(fg).bg(bg)) + .block( + Block::default() + .borders(Borders::ALL) + .title("Search Processes"), + ); + let overlay_area = Rect { + x: area.width.saturating_sub(40) / 2, + y: area.height.saturating_sub(5) / 2, + width: 40.min(area.width), + height: 3.min(area.height), + }; + f.render_widget(overlay, overlay_area); +} + +fn render_fullscreen(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + header::render(f, state, chunks[0]); + match state.full_screen_widget { + FullScreenWidget::Cpu => cpu::render(f, state, chunks[1]), + FullScreenWidget::Memory => memory::render(f, state, chunks[1]), + FullScreenWidget::Storage => storage::render(f, state, chunks[1]), + FullScreenWidget::Network => network::render(f, state, chunks[1]), + FullScreenWidget::Processes => processes::render(f, state, chunks[1]), + FullScreenWidget::DiskIO => disk_io::render(f, state, chunks[1]), + FullScreenWidget::Gpu => gpu::render(f, state, chunks[1]), + FullScreenWidget::Battery => battery::render(f, state, chunks[1]), + FullScreenWidget::None => {} + } +} + +fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) { + use ratatui::widgets::Gauge; + + let bg = to_color(state.current_theme.bg()); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Min(0), + ]) + .split(area); + + header::render(f, state, chunks[0]); + + let snap = state.snapshot(); + let cpu_pct = snap.cpus.first().map(|c| c.usage).unwrap_or(0.0); + let cpu_text = format!( + "CPU: {:>3.0}% | Mem: {:.1}/{:.1}G ({:>3.0}%)", + cpu_pct, + snap.memory.used as f64 / 1073741824.0, + snap.memory.total as f64 / 1073741824.0, + snap.memory.percent, + ); + let cpu_gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[1])) + .bg(bg), + ) + .percent(cpu_pct as u16) + .label(cpu_text); + f.render_widget(cpu_gauge, chunks[1]); + + let mem_pct = snap.memory.percent as u16; + let mem_text = format!( + "Mem: {:.1}/{:.1}G ({:>3.0}%)", + snap.memory.used as f64 / 1073741824.0, + snap.memory.total as f64 / 1073741824.0, + snap.memory.percent, + ); + let mem_gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[2])) + .bg(bg), + ) + .percent(mem_pct) + .label(mem_text); + f.render_widget(mem_gauge, chunks[2]); + + processes::render(f, state, chunks[3]); +} diff --git a/crates/xtop-tui/src/render/network.rs b/crates/xtop-tui/src/render/network.rs new file mode 100644 index 0000000..e1502b6 --- /dev/null +++ b/crates/xtop-tui/src/render/network.rs @@ -0,0 +1,150 @@ +use crate::color::to_color; +use crate::format::format_bytes; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Wrap}; +use ratatui::Frame; +use xtop_core::application::state::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + + let block = Block::default() + .title("Network") + .borders(Borders::ALL) + .border_set(border::DOUBLE) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(area); + f.render_widget(block, area); + + let snap = state.snapshot(); + let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum(); + let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum(); + let total_rx_speed: f64 = snap.networks.iter().map(|n| n.rx_speed).sum(); + let total_tx_speed: f64 = snap.networks.iter().map(|n| n.tx_speed).sum(); + + let has_chart = inner.height > 6; + + if has_chart { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(4), Constraint::Min(0)]) + .split(inner); + + render_stats(f, state, chunks[0], fg, total_rx, total_tx, total_rx_speed, total_tx_speed, &snap.networks); + render_net_chart(f, state, chunks[1], bg); + } else { + render_stats(f, state, inner, fg, total_rx, total_tx, total_rx_speed, total_tx_speed, &snap.networks); + } +} + +#[allow(clippy::too_many_arguments)] +fn render_stats( + f: &mut Frame, + state: &AppState, + area: Rect, + fg: Color, + total_rx: u64, + total_tx: u64, + total_rx_speed: f64, + total_tx_speed: f64, + interfaces: &[xtop_core::domain::metrics::NetworkInfo], +) { + let mut text = vec![ + Line::from(vec![ + Span::styled("RX: ", Style::default().fg(fg)), + Span::styled( + format_bytes(total_rx), + Style::default().fg(to_color(&state.current_theme.palette[4])), + ), + Span::raw(" "), + Span::styled( + format!("{}/s", format_bytes(total_rx_speed as u64)), + Style::default().fg(to_color(&state.current_theme.palette[4])), + ), + ]), + Line::from(vec![ + Span::styled("TX: ", Style::default().fg(fg)), + Span::styled( + format_bytes(total_tx), + Style::default().fg(to_color(&state.current_theme.palette[5])), + ), + Span::raw(" "), + Span::styled( + format!("{}/s", format_bytes(total_tx_speed as u64)), + Style::default().fg(to_color(&state.current_theme.palette[5])), + ), + ]), + ]; + + if area.height > 4 { + for iface in interfaces { + if text.len() as u16 >= area.height.saturating_sub(1) { + break; + } + text.push(Line::from(Span::raw(format!( + " {} RX: {} TX: {}", + iface.name, + format_bytes(iface.received), + format_bytes(iface.transmitted), + )))); + } + } + + let p = Paragraph::new(text).wrap(Wrap { trim: true }); + f.render_widget(p, area); +} + +fn render_net_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) { + let rx_data: Vec<(f64, f64)> = state.history.net_rx.iter().copied().collect(); + let tx_data: Vec<(f64, f64)> = state.history.net_tx.iter().copied().collect(); + if rx_data.len() < 2 || tx_data.len() < 2 { + return; + } + + // Find max value for y-axis bounds + let max_val = rx_data + .iter() + .chain(tx_data.iter()) + .map(|&(_, v)| v) + .fold(0.0_f64, f64::max) + .max(1.0); + + let datasets = vec![ + Dataset::default() + .name("RX") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(to_color(&state.current_theme.palette[4]))) + .data(&rx_data), + Dataset::default() + .name("TX") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(to_color(&state.current_theme.palette[5]))) + .data(&tx_data), + ]; + + let x_min = rx_data.first().map(|&(x, _)| x).unwrap_or(0.0); + let x_max = rx_data.last().map(|&(x, _)| x).unwrap_or(100.0); + let x_max = x_max.max(x_min + 1.0); + + let chart = Chart::new(datasets) + .block(Block::default().borders(Borders::TOP)) + .x_axis( + Axis::default() + .bounds([x_min, x_max]) + .labels(vec![Span::raw("")]), + ) + .y_axis( + Axis::default() + .bounds([0.0, max_val]) + .labels(vec![ + Span::raw("0"), + Span::raw(format!("{:.0}", max_val / 2.0)), + Span::raw(format!("{:.0}", max_val)), + ]), + ); + f.render_widget(chart, area); +} diff --git a/crates/xtop-tui/src/render/palette.rs b/crates/xtop-tui/src/render/palette.rs new file mode 100644 index 0000000..54f4c11 --- /dev/null +++ b/crates/xtop-tui/src/render/palette.rs @@ -0,0 +1,89 @@ +use crate::color::to_color; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; +use xtop_core::application::state::{AppState, PalettePage}; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + let accent = to_color(&state.current_theme.palette[6]); + + let popup_width = (area.width as f64 * 0.6).min(60.0) as u16; + let popup_height = (area.height as f64 * 0.6).min(30.0) as u16; + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + let title = state.palette.title(); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(accent)) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(popup); + f.render_widget(block, popup); + + let hint = if state.palette.page == PalettePage::Main { + " Filter: type to search · navigate with ↑↓ · select with Enter" + } else { + " Filter themes · Enter to select · Esc/Bksp to go back" + }; + let input_label = match state.palette.page { + PalettePage::Main => "Action", + PalettePage::Themes => "Theme", + PalettePage::Layouts => "Layout", + }; + + let search_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 3, + }; + let input_text = if state.palette.query.is_empty() { + format!(" {}_", hint) + } else { + format!(" {}: {}", input_label, state.palette.query) + }; + let input = Paragraph::new(input_text.as_str()) + .style(Style::default().fg(accent).bg(bg)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(input, search_area); + + let list_area = Rect { + x: inner.x, + y: inner.y + 3, + width: inner.width, + height: inner.height.saturating_sub(3).max(1), + }; + + let items: Vec = state + .palette + .filtered + .iter() + .map(|&entry_idx| { + let entry = &state.palette.entries[entry_idx]; + ListItem::new(entry.label.as_str()).style(Style::default().fg(fg)) + }) + .collect(); + + let mut list_state = ListState::default(); + list_state.select(Some(state.palette.selected)); + + let list = List::new(items) + .highlight_style( + Style::default() + .fg(bg) + .bg(fg) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▸ "); + f.render_stateful_widget(list, list_area, &mut list_state); +} diff --git a/crates/xtop-tui/src/render/processes.rs b/crates/xtop-tui/src/render/processes.rs new file mode 100644 index 0000000..23cd5e5 --- /dev/null +++ b/crates/xtop-tui/src/render/processes.rs @@ -0,0 +1,111 @@ +use crate::color::to_color; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Cell, Row, Table}; +use ratatui::Frame; +use xtop_core::application::state::AppState; +use xtop_core::domain::metrics::ProcessInfo; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + let dim_bg = to_color(&state.current_theme.palette[8]); + let accent = to_color(&state.current_theme.palette[6]); + + let mut title = format!("Processes (sort: {})", state.process_sort.label()); + if !state.search_query.is_empty() { + title = format!("Processes (filter: {})", state.search_query); + } + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_set(border::PLAIN) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(area); + f.render_widget(block, area); + + let snap = state.snapshot(); + + let iter: Box> = if state.search_query.is_empty() { + Box::new(snap.processes.iter()) + } else { + let q = state.search_query.to_lowercase(); + Box::new( + snap.processes + .iter() + .filter(move |p| p.name.to_lowercase().contains(&q)), + ) + }; + + let mut items: Vec<&ProcessInfo> = iter.collect(); + + // Sort + match state.process_sort { + xtop_core::application::state::ProcessSortBy::Cpu => { + items.sort_by(|a, b| b.cpu_usage.partial_cmp(&a.cpu_usage).unwrap_or(std::cmp::Ordering::Equal)); + } + xtop_core::application::state::ProcessSortBy::Memory => { + items.sort_by_key(|b| std::cmp::Reverse(b.memory)); + } + xtop_core::application::state::ProcessSortBy::Pid => { + items.sort_by_key(|a| a.pid); + } + xtop_core::application::state::ProcessSortBy::Name => { + items.sort_by_key(|a| a.name.to_lowercase()); + } + } + + let rows: Vec = items + .into_iter() + .enumerate() + .map(|(row_idx, p)| { + let is_selected = state.process_selected == Some(row_idx); + let style = if is_selected { + Style::default() + .fg(bg) + .bg(accent) + .add_modifier(Modifier::BOLD) + } else if row_idx % 2 == 0 { + Style::default().fg(fg) + } else { + Style::default().fg(fg).bg(dim_bg) + }; + Row::new(vec![ + Cell::from(p.pid.to_string()), + Cell::from(p.name.clone()), + Cell::from(format!("{:.1}%", p.cpu_usage)), + Cell::from(crate::format::format_bytes(p.memory)), + Cell::from(p.user_id.clone().unwrap_or_else(|| "?".to_string())), + ]) + .style(style) + }) + .collect(); + + let widths = [ + Constraint::Length(10), + Constraint::Percentage(40), + Constraint::Length(12), + Constraint::Length(17), + Constraint::Length(10), + ]; + + let table = Table::new(rows, widths) + .header( + Row::new(vec!["PID", "Name", "CPU%", "Mem", "User"]) + .style( + Style::default() + .fg(accent) + .add_modifier(Modifier::BOLD), + ) + .bottom_margin(1), + ) + .row_highlight_style( + Style::default() + .fg(bg) + .bg(accent) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(table, inner); +} diff --git a/crates/xtop-tui/src/render/storage.rs b/crates/xtop-tui/src/render/storage.rs new file mode 100644 index 0000000..182a7ac --- /dev/null +++ b/crates/xtop-tui/src/render/storage.rs @@ -0,0 +1,58 @@ +use crate::color::{gauge_gradient, to_color}; +use crate::format::format_bytes; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Gauge}; +use ratatui::Frame; +use xtop_core::application::state::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(state.current_theme.fg()); + let bg = to_color(state.current_theme.bg()); + + let block = Block::default() + .title("Storage") + .borders(Borders::ALL) + .border_set(border::DOUBLE) + .style(Style::default().fg(fg).bg(bg)); + let inner = block.inner(area); + f.render_widget(block, area); + + let snap = state.snapshot(); + let disks = &snap.disks; + if disks.is_empty() { + return; + } + + let per_disk = inner.height.min(3); + let constraints = vec![Constraint::Length(per_disk); disks.len()]; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(inner); + + for (i, disk) in disks.iter().enumerate() { + if i >= chunks.len() { + break; + } + let color_idx = gauge_gradient(disk.percent, state.alerts.disk_high); + let fs_type = &disk.file_system; + let label = format!( + "{} [{}] Tot: {} Use: {} Free: {}", + disk.mount_point, + if fs_type.is_empty() { "?" } else { fs_type }, + format_bytes(disk.total_space), + format_bytes(disk.used_space), + format_bytes(disk.available_space), + ); + let gauge = Gauge::default() + .gauge_style( + Style::default() + .fg(to_color(&state.current_theme.palette[color_idx])) + .bg(bg), + ) + .percent(disk.percent as u16) + .label(label); + f.render_widget(gauge, chunks[i]); + } +} diff --git a/src/tui.rs b/crates/xtop-tui/src/terminal.rs similarity index 86% rename from src/tui.rs rename to crates/xtop-tui/src/terminal.rs index 03718b5..37df6e7 100644 --- a/src/tui.rs +++ b/crates/xtop-tui/src/terminal.rs @@ -1,10 +1,10 @@ -use std::io::{self, Stdout}; -use ratatui::{backend::CrosstermBackend, Terminal}; use crossterm::{ - event::{EnableMouseCapture, DisableMouseCapture}, + event::{DisableMouseCapture, EnableMouseCapture}, execute, - terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io::{self, Stdout}; use std::panic; pub type Tui = Terminal>; diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..261bf2f --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,89 @@ +

Configuration

+ +
+ +

Config File

+ +

xtop automatically saves its configuration on quit. The configuration file is located at:

+ +
~/.config/xtop/config.json
+ +
+ +

Persisted Settings

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SettingDescription
themeCurrently selected color theme name
layoutCurrently selected layout mode
intervalUpdate interval in milliseconds
history_pointsNumber of data points retained for the RAM history chart
alert_thresholdsThreshold values for CPU, memory, and disk alerts
+ +
+ +

Alert Thresholds

+ +

When a metric exceeds its configured threshold, the corresponding widget changes color to red and displays a warning indicator in its title.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ThresholdDescriptionDefault
cpu_highCPU usage percentage that triggers a warning90%
mem_highMemory usage percentage that triggers a warning90%
disk_highDisk usage percentage that triggers a warning90%
+ +
+ +

Custom Themes and Layouts

+ +

For custom themes and layouts, see the customization guide.

+ +
+ +

+ Back to README +

diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 0000000..e93ff7d --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,381 @@ +

Customization Guide

+ +

xtop supports runtime customization of color themes and layout modes via external JSONC files. This guide explains how to create and manage your own themes and layouts.

+ +
+ +

Table of Contents

+ + + +
+ +

Themes

+ +

Location

+ +

Place theme files in:

+ +
~/.config/xtop/themes/*.jsonc
+~/.config/xtop/themes/*.json
+
+ +

The directory is created automatically when you save your config on quit.

+ +

Format

+ +

Each theme file defines a name and a 16-entry palette. Colors are hex strings with an optional # prefix. Comments (// and /* */) are supported in JSONC files.

+ +
{
+    // my-custom-theme -- Dark background, warm accents
+    "name": "my-custom-theme",
+    "palette": [
+        "#1a1b1c", //  0: background
+        "#e06c75", //  1: red / alert
+        "#98c379", //  2: green (RAM gauge)
+        "#e5c07b", //  3: yellow (Swap gauge)
+        "#d19a66", //  4: orange (Storage, Network TX)
+        "#c678dd", //  5: purple (GPU)
+        "#56b6c2", //  6: cyan (accents, table headers)
+        "#abb2bf", //  7: foreground / text
+        "#3e4451", //  8: bright black (separators)
+        "#e06c75", //  9: bright red
+        "#98c379", // 10: bright green
+        "#e5c07b", // 11: bright yellow
+        "#d19a66", // 12: bright orange
+        "#c678dd", // 13: bright purple
+        "#56b6c2", // 14: bright cyan
+        "#abb2bf"  // 15: bright white
+    ]
+}
+ +

Palette Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IndexUsageExample
0Background#1a1b1c
1Red / Alert#e06c75
2Green (RAM gauge)#98c379
3Yellow (Swap gauge)#e5c07b
4Orange (Storage, Network TX)#d19a66
5Purple (GPU)#c678dd
6Cyan (Accents, table headers)#56b6c2
7Foreground / Text#abb2bf
8Bright black (Separators)#3e4451
9Bright red#e06c75
10Bright green#98c379
11Bright yellow#e5c07b
12Bright orange#d19a66
13Bright purple#c678dd
14Bright cyan#56b6c2
15Bright white#abb2bf
+ +

Starter Themes

+ +

The built-in default theme is x (almost-black background, purple-pink accents). It is compiled into the binary and always available.

+ +

When you run xtop for the first time, it automatically creates ~/.config/xtop/themes/ with all extra themes embedded in the binary. No manual copy is needed.

+ +

If you want to restore them later, copy from the repository:

+ +
cp -r assets/themes/* ~/.config/xtop/themes/
+ +

Available themes: x, madrid, lahabana, paris, tokio, oslo, helsinki, berlin, london, praha, bogota, miami.

+ +

All theme definitions are documented in colors.md.

+ +

Loading Order

+ +
    +
  1. Built-in miami theme (always available)
  2. +
  3. Themes from ~/.config/xtop/themes/ loaded alphabetically
  4. +
  5. If a custom theme has the same name as miami, it replaces the built-in
  6. +
+ +

Tips

+ +
    +
  • Try the grayscale themes (london, berlin) as a base and add your own accent colors.
  • +
  • Palette entries 8–15 (the "bright" variants) are used for separators and secondary text.
  • +
  • Index 0 is the background, index 7 is the primary foreground — keep them readable together.
  • +
+ +
+ +

Layouts

+ +

Location

+ +

Place layout files in:

+ +
~/.config/xtop/layouts/*.jsonc
+~/.config/xtop/layouts/*.json
+
+ +

Format

+ +

A layout is a recursive tree of splits and widgets:

+ +
LayoutDef
+ ├── name: string
+ └── root: Area
+      ├── direction: "horizontal" | "vertical"
+      ├── size: constraint (optional, defaults to "*")
+      └── areas: [Area, ...]
+           ├── Area with "widget" → leaf node (renders a widget)
+           └── Area with "direction" → nested split
+
+ +

Size Constraints

+ + + + + + + + + + + + + + + + + + + + + + +
SyntaxMeaning
"*" or omittedFill remaining space
3 (number)Fixed n rows/columns
"45%"Percentage of parent
+ +

Available Widgets

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WidgetDescription
headerSystem info bar (uptime, load, keys)
cpuPer-core CPU usage gauges
memoryRAM + Swap gauges + RAM history chart
storageDisk usage gauges per mount point
networkNetwork RX/TX totals and speeds
processesProcess table with search filter
disk_ioDisk read/write speeds
batteryBattery charge gauges
gpuGPU usage gauges
+ +

Examples

+ +

Simple custom layout

+ +

A minimal three-row layout: header, CPU, and processes.

+ +
{
+    // "monitor" — CPU top-half, processes bottom-half
+    "name": "monitor",
+    "root": {
+        "direction": "vertical",
+        "areas": [
+            { "widget": "header", "size": 3 },
+            { "widget": "cpu", "size": "55%" },
+            { "widget": "processes", "size": "*" }
+        ]
+    }
+}
+ +

Complex nested layout

+ +

A full dashboard with a horizontal split in the middle section:

+ +
{
+    "name": "my-dashboard",
+    "root": {
+        "direction": "vertical",
+        "areas": [
+            { "widget": "header", "size": 3 },
+            {
+                "direction": "horizontal",
+                "size": "50%",
+                "areas": [
+                    { "widget": "cpu", "size": "60%" },
+                    {
+                        "direction": "vertical",
+                        "size": "40%",
+                        "areas": [
+                            { "widget": "network", "size": "50%" },
+                            { "widget": "disk_io", "size": "50%" }
+                        ]
+                    }
+                ]
+            },
+            { "widget": "processes", "size": "*" }
+        ]
+    }
+}
+ +

Starter Layouts

+ +

The 7 built-in layouts are embedded in the binary and written to ~/.config/xtop/layouts/ on first run.

+ +

To restore them later, copy from the repository:

+ +
cp -r assets/layouts/* ~/.config/xtop/layouts/
+ +

Available layouts: dashboard, vertical, horizontal, cpu_focus, memory_focus, network_focus, process_focus.

+ +

Cycling Order

+ +
    +
  1. Built-in layouts (Dashboard → Vertical → Horizontal → CPU Focus → Memory Focus → Network Focus → Process Focus)
  2. +
  3. Custom layouts from ~/.config/xtop/layouts/ (in filesystem order)
  4. +
  5. Wraps back to Dashboard
  6. +
+ +

Press l to cycle forward through all available layouts.

+ +

Notes

+ +
    +
  • If a widget name in your layout doesn't match any available widget, that area is silently skipped.
  • +
  • Nested splits can be arbitrarily deep, but very deep nesting may overflow small terminals.
  • +
  • The terminal must be at least 40×8 for any layout to render; smaller terminals show a warning.
  • +
  • Very small terminals (under 60×14) fall back to a minimal hardcoded layout (CPU + Memory gauges + process list).
  • +
+ +
+ +

+ ← Back to README +

diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..18f3c08 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,112 @@ +

Features

+ +

Detailed breakdown of xtop's monitoring and interface capabilities.

+ +
+ +

System Monitoring

+ +

CPU

+ +
    +
  • Usage percentage per core and per thread, displayed as horizontal gauges.
  • +
  • Maximum CPU temperature reading when hardware sensors are available.
  • +
  • Color-coded bars that visually indicate load levels.
  • +
+ +

Memory

+ +
    +
  • RAM usage gauge showing used, total, and percentage.
  • +
  • Swap usage gauge.
  • +
  • Historical line chart tracking RAM usage over time.
  • +
  • Configurable number of history data points.
  • +
+ +

Network

+ +
    +
  • Real-time upload (TX) and download (RX) tracking per network interface.
  • +
  • Total data transferred displayed alongside current transfer speeds.
  • +
+ +

Storage

+ +
    +
  • Disk usage gauges per mount point showing used, available, and total space.
  • +
  • Visual percentage bars for each mounted filesystem.
  • +
+ +

Disk I/O

+ +
    +
  • Read and write speed tracking per disk device.
  • +
  • Displayed in bytes per second with automatic unit scaling.
  • +
+ +

Processes

+ +
    +
  • Scrolling list of running processes sorted by CPU usage.
  • +
  • Live search filtering by process name.
  • +
  • Displays process name, CPU usage, and memory usage.
  • +
+ +

GPU

+ +
    +
  • GPU usage gauges (stub implementation, ready for NVIDIA/AMD support).
  • +
+ +

Battery

+ +
    +
  • Battery charge level gauges (stub implementation, ready for laptop support).
  • +
+ +
+ +

Theming

+ +
    +
  • 13 ready-to-use color schemes built into the binary.
  • +
  • Custom themes defined as JSONC files with a 16-entry hex color palette.
  • +
  • Instant theme cycling with t (next) and T (previous).
  • +
  • Starter theme files ship in the assets/themes/ directory.
  • +
+ +
+ +

Layouts

+ +
    +
  • 7 built-in layout modes: Dashboard, Vertical, Horizontal, CPU Focus, Memory Focus, Network Focus, Process Focus.
  • +
  • Custom layouts defined as JSONC files with a recursive split/widget tree.
  • +
  • Full-screen mode for any widget toggled with f.
  • +
  • Responsive design that adapts to terminal width and height automatically.
  • +
  • Minimal fallback layout for very small terminals.
  • +
+ +
+ +

Alert Thresholds

+ +
    +
  • Visual warnings when CPU, memory, or disk usage exceeds configurable limits.
  • +
  • Color changes to red and warning indicators in widget titles.
  • +
+ +
+ +

Persistence

+ +
    +
  • Current theme, layout mode, update interval, history points, and alert thresholds are saved automatically on quit.
  • +
  • Configuration is stored at ~/.config/xtop/config.json.
  • +
+ +
+ +

+ Back to README +

diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..5cd063e --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,95 @@ +

Installation Guide

+ +
+ +

Table of Contents

+ + + +
+ +

Quick Install (macOS/Linux)

+ +

The installer script automatically detects your distribution and installs all required dependencies, including Rust if needed.

+ +

Using curl

+ +
curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
+ +

Using wget

+ +
wget -qO- https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
+ +
+ +

Quick Install (Windows)

+ +

Requires Rust (Cargo) to be installed. Run in PowerShell:

+ +
irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
+ +
+ +

Installer Options

+ +

You can run the installer script with additional flags for more control:

+ +
# Check dependencies without installing
+./install.sh --check-deps
+
+# Install only dependencies (Rust, build tools)
+./install.sh --install-deps
+
+# Show help
+./install.sh --help
+ +

Supported Distributions

+ +
    +
  • Arch Linux and derivatives
  • +
  • Debian / Ubuntu and derivatives
  • +
  • Fedora / RHEL and derivatives
  • +
  • openSUSE and derivatives
  • +
  • Alpine Linux and derivatives
  • +
+ +
+ +

Build from Source

+ +
    +
  1. +

    Clone the repository:

    +
    git clone https://github.com/xscriptor/xtop.git
    +cd xtop
    +
  2. +
  3. +

    Build and run with release optimizations:

    +
    cargo run --release
    +
  4. +
+ +
+ +

Uninstall

+ +

macOS / Linux

+ +
curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -s -- --uninstall
+ +

Windows

+ +
irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex
+ +
+ +

+ Back to README +

diff --git a/docs/plugin.md b/docs/plugin.md new file mode 100644 index 0000000..7612148 --- /dev/null +++ b/docs/plugin.md @@ -0,0 +1,282 @@ +

Plugin System

+ +

xtop has a compile-time plugin system based on Rust feature flags. Plugins live in the plugins/ directory and are registered into the workspace as optional dependencies.

+ +

You can build xtop without any plugins:

+ +
cargo build --release --no-default-features
+ +

Or with a specific set of plugins:

+ +
cargo build --release --features plugin-sentinel
+ +
+ +

Architecture

+ +

The plugin system has four main components:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentLocationPurpose
Plugin traitxtop-core::domain::pluginInterface every plugin must implement
PluginManagerxtop-core::application::plugin_managerLoads, ticks, and dispatches events to plugins
CompositeProviderxtop-core::infrastructure::composite_providerMerges data from primary provider + plugin providers
WidgetRegistrationxtop-core::domain::pluginAllows plugins to register custom TUI widgets
+ +
+ +

The Plugin Trait

+ +
pub trait Plugin: Debug + Send {
+    fn manifest(&self) -> PluginManifest;
+    fn on_enable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
+    fn on_disable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
+    fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
+    fn on_key(&mut self, ctx: &mut PluginContext, key: &str) -> Result<bool, PluginError>;
+    fn data_provider(&self) -> Option<Box<dyn SystemDataProvider>>;
+    fn widget(&self) -> Option<WidgetRegistration>;
+    fn execute(&mut self, ctx: &mut PluginContext, action: &str, params: &str) -> Result<String, PluginError>;
+}
+ + + + + + + + + + + + + + + +
MethodDefaultCalled When
manifest()requiredAny time metadata is needed
on_enable()no-opPlugin is registered at startup
on_disable()no-opxtop shuts down
on_tick()no-opEvery update cycle (~1s)
on_key()returns falseKey press (consumes if returns true)
data_provider()NoneStartup (merged into CompositeProvider)
widget()NoneEvery tick (refreshes widget registry)
execute()UnknownActionExternal agent (AI, CLI, IPC) invokes command
+ +

PluginCapability

+ +

Each plugin declares what it needs via manifest().capabilities:

+ +
    +
  • ReadSystemInfo -- access system metrics
  • +
  • KillProcesses -- terminate processes
  • +
  • ModifyConfig -- change themes, layouts, thresholds
  • +
  • RenderWidgets -- register custom TUI widgets
  • +
  • Custom(&str) -- anything not covered above
  • +
+ +

PluginContext

+ +

Safe, limited access to application state:

+ +
ctx.snapshot()             // Full SystemSnapshot
+ctx.top_processes(n)       // Top N processes by CPU
+ctx.kill_process(pid)      // Kill process by PID
+ctx.set_alert_thresholds(cpu, mem, disk)
+ctx.set_theme_by_name("tokio")
+ctx.set_layout_by_name("Dashboard")
+ctx.set_update_interval(500)
+ctx.system_info()          // Hostname, OS, kernel
+ctx.data_dir()             // ~/.config/xtop/plugins/<id>/
+ +
+ +

CompositeProvider

+ +

The CompositeProvider wraps the primary SysinfoProvider and merges data from plugin providers:

+ +
    +
  • refresh_all() refreshes all providers
  • +
  • snapshot() delegates to primary for CPU, memory, disks, networks, processes
  • +
  • gpu_info(), batteries(), docker_info() check extras if primary returns empty
  • +
  • kill_process() tries primary first, then extras
  • +
+ +
+ +

Widget Registration

+ +

Plugins can register custom widgets via widget():

+ +
fn widget(&self) -> Option<WidgetRegistration> {
+    Some(WidgetRegistration {
+        name: "sentinel".to_string(),
+        render: Arc::new(|f, state, area| {
+            // Draw using ratatui
+        }),
+    })
+}
+ +

Custom widgets are placed in layouts by name in JSONC layout files:

+ +
{
+    "name": "my-layout",
+    "root": {
+        "direction": "vertical",
+        "areas": [
+            { "widget": "header", "size": 3 },
+            { "widget": "sentinel", "size": "30%" },
+            { "widget": "processes", "size": "*" }
+        ]
+    }
+}
+ +

Plugin widgets take precedence over built-in widgets with the same name.

+ +
+ +

CLI Commands

+ + + + + + + + + + + + + + + + + + + + + + + +
CommandDescription
xtop plugin listList installed plugins from workspace members
xtop plugin install <name>Install a plugin from github.com/xscriptor/xtop/plugins/
xtop plugin install <url>Install a plugin from any git URL
xtop plugin scaffold <name>Create a new plugin crate template in plugins/
+ +

Install Flow

+ +

When running xtop plugin install sentinel:

+ +
    +
  1. Clones github.com/xscriptor/xtop.git (shallow, sparse)
  2. +
  3. Looks for plugins/xtop-plugin-sentinel/ or plugins/sentinel/ in the clone
  4. +
  5. Copies to local plugins/ directory
  6. +
  7. Adds entry to [workspace].members in root Cargo.toml
  8. +
  9. Adds optional dependency + feature flag in crates/xtop-cli/Cargo.toml
  10. +
  11. Runs cargo build --release
  12. +
  13. Cleans up temporary files
  14. +
+ +

The plugin is registered in the workspace but not enabled by default. To enable it:

+ +
    +
  • Build with --features plugin-<name> for a one-off build
  • +
  • Add it to the default list in crates/xtop-cli/Cargo.toml to enable permanently
  • +
+ +
# Build xtop with sentinel plugin enabled
+cargo build --release --features plugin-sentinel
+
+# Build xtop with sentinel + another plugin
+cargo build --release --features "plugin-sentinel,plugin-mything"
+ +
+ +

MCP Server for AI Agents

+ +

When the plugin-sentinel feature is enabled, xtop can run an MCP (Model Context Protocol) server on stdio:

+ +
xtop mcp
+ +

This exposes Sentinel's commands as MCP tools that any AI assistant can call. +Compatible clients include Claude Desktop, Cline, Cursor, and Continue.dev.

+ +

Claude Desktop configuration

+ +
{
+  "mcpServers": {
+    "xtop": {
+      "command": "xtop",
+      "args": ["mcp"]
+    }
+  }
+}
+ +

Interactive testing

+ +
# Single command
+echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_summary","arguments":{}}}' | xtop mcp
+
+# Interactive session
+xtop mcp
+ +

Monitoreo activo (polling)

+ +

La IA puede llamar system_summary o alerts.status periodicamente. Para monitoreo proactivo (push), haria falta implementar MCP Resources + notificaciones.

+ +
+ +

Adding a Plugin Manually

+ +
    +
  1. +

    Create the crate in plugins/:

    +
    mkdir -p plugins/xtop-plugin-mything/src
    +
  2. +
  3. +

    Add to workspace Cargo.toml:

    +
    [workspace]
    +members = [
    +    ...
    +    "plugins/xtop-plugin-mything",
    +]
    +
  4. +
  5. +

    Add dependency + feature in crates/xtop-cli/Cargo.toml:

    +
    [dependencies]
    +xtop-plugin-mything = { path = "../../plugins/xtop-plugin-mything", optional = true }
    +
    +[features]
    +plugin-mything = ["dep:xtop-plugin-mything"]
    +
  6. +
  7. +

    Register in crates/xtop-cli/src/main.rs:

    +
    #[cfg(feature = "plugin-mything")]
    +use xtop_plugin_mything::MythingPlugin;
    +
    +// In build_plugin_manager():
    +#[cfg(feature = "plugin-mything")]
    +{
    +    let plugin = Box::new(MythingPlugin::new());
    +    if let Err(e) = mgr.register(plugin, state) {
    +        eprintln!("[xtop] failed to load mything plugin: {e}");
    +    }
    +}
    +
  8. +
+ +
+ +

+ Back to README +

diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..f226ba6 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,180 @@ +

Usage

+ +
+ +

Table of Contents

+ + + +
+ +

Keybindings

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyAction
qQuit application (saves configuration)
?Toggle help overlay
tNext color theme
TPrevious color theme
lNext layout mode (cycles through built-in and custom layouts)
fToggle full-screen mode for the current widget
FCycle full-screen focus through all available widgets
/Open process search / filter
EnterConfirm search filter
BackspaceDelete character in search input
EscCancel search / close help overlay
+ +
+ +

Modules

+ +
    +
  1. +

    Header -- Shows system uptime, load average, current theme name, and active layout mode.

    +
  2. +
  3. +

    CPU -- Horizontal usage bars for each CPU core. If hardware sensors are available, displays the maximum CPU temperature.

    +
  4. +
  5. +

    Memory -- Gauges for RAM and Swap usage, plus a line chart showing RAM usage over a configurable history window.

    +
  6. +
  7. +

    Storage -- Disk usage gauges per mount point showing capacity and used space.

    +
  8. +
  9. +

    Network -- Total downloaded (RX) and uploaded (TX) data per interface, with current transfer rates.

    +
  10. +
  11. +

    Disk I/O -- Read and write speeds per disk device in bytes per second.

    +
  12. +
  13. +

    Processes -- Scrolling list of the top 50 processes sorted by CPU usage, with live search filtering by process name.

    +
  14. +
  15. +

    GPU -- GPU usage gauges (available on supported hardware).

    +
  16. +
  17. +

    Battery -- Battery charge level gauges (available on supported hardware).

    +
  18. +
+ +
+ +

Help Overlay

+ +

Press ? at any time to display a full list of available keybindings on screen. Press ? again or Esc to close the overlay.

+ +
+ +

Command Palette

+ +

xtop provides an interactive search overlay for filtering processes in real time:

+ +
    +
  • Press / to open the search bar at the top of the process list.
  • +
  • Type any query -- results filter instantly by process name.
  • +
  • Press Enter to confirm the filter, Esc to cancel, Backspace to delete characters.
  • +
  • A centered overlay with a /query_ indicator shows the current search input.
  • +
+ +

The help overlay (?) serves as a quick-reference command palette for all available keybindings and actions.

+ +
+ +

Full-Screen Mode

+ +
    +
  • Press f to toggle full-screen mode for the currently focused widget. The widget expands to fill the entire terminal area (minus the header bar).
  • +
  • Press F to cycle full-screen focus through widgets in sequence: CPU, Memory, Storage, Network, Processes, Disk I/O, GPU, Battery, then exit.
  • +
+ +
+ +

Responsive Layouts

+ +

The interface adapts automatically to the terminal size:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Terminal SizeBehavior
Wider than 100 cols and taller than 30 rowsFull dashboard layout with 2 columns
Wider than 80 cols and taller than 24 rowsCompact layout
Narrower than 80 colsVertically stacked layout
Narrower than 60 cols or shorter than 18 rowsMinimal layout: CPU, Memory, and Processes only
Smaller than 40 x 8Warning message displayed (terminal too small)
+ +
+ +

+ Back to README +

diff --git a/plugins/xtop-plugin-sentinel/Cargo.toml b/plugins/xtop-plugin-sentinel/Cargo.toml new file mode 100644 index 0000000..7bb99d8 --- /dev/null +++ b/plugins/xtop-plugin-sentinel/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "xtop-plugin-sentinel" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "AI-aware system resource monitoring and management plugin for xtop" + +[dependencies] +xtop-core = { path = "../../crates/xtop-core" } +ratatui.workspace = true +regex = "1" +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true diff --git a/plugins/xtop-plugin-sentinel/README.md b/plugins/xtop-plugin-sentinel/README.md new file mode 100644 index 0000000..4128f91 --- /dev/null +++ b/plugins/xtop-plugin-sentinel/README.md @@ -0,0 +1,372 @@ +

Sentinel Plugin

+ +

Sentinel is an AI-aware system monitoring and management plugin for xtop. It exposes system metrics, process information, and configuration through a simple command interface designed to be consumed by AI agents (including LLM-based assistants).

+ +

It also includes a built-in MCP server (xtop mcp) that exposes all commands as MCP tools for seamless AI integration.

+ +
+ +

Features

+ +
    +
  • Read system summary (CPU, memory, disks, network, uptime) as JSON
  • +
  • Query top processes with full details and optional regex filter
  • +
  • Search processes by regex on name, cmd, user, or state fields
  • +
  • Kill processes safely by PID
  • +
  • Get and set alert thresholds
  • +
  • Read and update xtop configuration (theme, layout, interval)
  • +
  • Automatic process anomaly detection (CPU spikes, root processes)
  • +
  • Custom TUI widget for visual status
  • +
  • MCP server over stdio for AI tool integration
  • +
+ +
+ +

Command Interface

+ +

All interaction happens through a single entry point:

+ +
plugin.execute(ctx, "<action>", "<params>") -> Result<String, PluginError>
+ +

The response is always a JSON string, making it easy for any AI or script to parse.

+ +

system.summary

+ +

Returns a high-level snapshot of system health.

+ + + + + + +
ParamsResponse
(empty)JSON object
+ +

Example response:

+ +
{
+    "cpu_avg": 23.4,
+    "mem_used_gb": 8.2,
+    "mem_total_gb": 32.0,
+    "mem_pct": 26,
+    "processes": 342,
+    "disks": 4,
+    "interfaces": ["en0", "en1"],
+    "uptime_secs": 482000,
+    "hostname": "mbp.local"
+}
+ +

processes.top

+ +

Returns the top N processes sorted by CPU usage.

+ + + + + + +
ParamsResponse
Count (e.g. "10")JSON array
+ +

Example response:

+ +
[
+    {"pid": 1234, "name": "firefox", "cpu": 45.2, "mem_bytes": 1048576000, "state": "Running", "user": "1000"},
+    {"pid": 5678, "name": "python3", "cpu": 12.1, "mem_bytes": 524288000, "state": "Sleeping", "user": "1000"}
+]
+ +

process.info

+ +

Returns detailed information about a single process.

+ + + + + + +
ParamsResponse
PID (e.g. "1234")JSON object
+ +

Example response:

+ +
{
+    "pid": 1234,
+    "name": "firefox",
+    "cpu": 45.2,
+    "mem_bytes": 1048576000,
+    "state": "Running",
+    "cmd": "/usr/lib/firefox/firefox"
+}
+ +

process.kill

+ +

Terminates a process by PID.

+ + + + + + +
ParamsResponse
PID (e.g. "1234")JSON object
+ +

Example response:

+ +
{"killed": true, "pid": 1234}
+ +

threshold.set

+ +

Sets alert thresholds for CPU, memory, and disk usage.

+ + + + + + +
ParamsResponse
"cpu,mem,disk" (percentages)JSON object
+ +

Example: "90,85,80" sets CPU threshold to 90%, memory to 85%, disk to 80%.

+ +

Response:

+ +
{"cpu": 90, "mem": 85, "disk": 80, "set": true}
+ +

threshold.get

+ +

Returns the current alert thresholds.

+ + + + + + +
ParamsResponse
(empty)JSON object
+ +

Response:

+ +
{"cpu": 90, "mem": 90, "disk": 90}
+ +

config.get

+ +

Returns the current xtop configuration.

+ + + + + + +
ParamsResponse
(empty)JSON object
+ +

Response:

+ +
{"theme": "x", "layout": "Dashboard", "interval_ms": 1000, "hostname": "mbp.local"}
+ +

alerts.status

+ +

Returns any alerts generated by process analysis heuristics.

+ + + + + + +
ParamsResponse
(empty)JSON object
+ +

Response:

+ +
{"alerts": "high_cpu: firefox (pid=1234, cpu=95.2%)\\nhigh_cpu: python3 (pid=5678, cpu=88.0%)"}
+ +

processes.search

+ +

Search processes using a regex pattern. Supports filtering on specific fields.

+ + + + + + +
ParamsResponse
"pattern" or "pattern,fields=f1,f2"JSON array of matching processes
+ +

Supported fields: name, cmd, user, state. Default: name only.

+ +

Examples:

+ +
# Search by name
+processes.search("firefox")
+
+# Regex on name
+processes.search("/python|node/")
+
+# Regex on name AND cmd
+processes.search("/systemd/,fields=name,cmd")
+
+# Filter by user
+processes.search("/^0$/,fields=user")
+ +

Response:

+ +
[{"pid": 1234, "name": "firefox", "cpu": 45.2, "mem_bytes": 1048576000, "state": "Running", "user": "1000", "cmd": "/usr/lib/firefox/firefox"}]
+ +

processes.top (with filter)

+ +

Top processes with optional regex filter on name or cmd:

+ +
# Top 5 processes matching the pattern
+processes.top("5,filter=/firefox/")
+ +

config.set

+ +

Update xtop configuration at runtime.

+ + + + + + +
ParamsResponse
"interval_ms=<ms>" or "theme=<name>" or "layout=<name>"JSON object
+ +

Examples:

+ +
config.set("interval_ms=2000")   # {"interval_ms": 2000, "set": true}
+config.set("theme=tokio")        # {"theme": "tokio", "set": true}
+config.set("layout=Vertical")    # {"layout": "Vertical", "set": true}
+ +

plugin.status

+ +

Returns the internal state of the Sentinel plugin.

+ + + + + + +
ParamsResponse
(empty)JSON object
+ +

Response:

+ +
{"enabled": true, "ticks": 42, "last_action": "system.summary", "last_result": "ok (147 chars)"}
+ +
+ +

TUI Widget

+ +

Sentinel registers a widget named "sentinel". Include it in any custom layout by referencing it in a JSONC layout file:

+ +
{
+    "name": "monitor",
+    "root": {
+        "direction": "vertical",
+        "areas": [
+            { "widget": "header", "size": 3 },
+            { "widget": "sentinel", "size": 6 },
+            { "widget": "processes", "size": "*" }
+        ]
+    }
+}
+ +

The widget renders a bordered panel with dark background and accent-colored title showing the agent status and a help hint.

+ +
+ +

MCP Server

+ +

Sentinel includes a built-in MCP server that exposes all commands as MCP tools. +Run it with:

+ +
xtop mcp
+ +

The server speaks JSON-RPC 2.0 over stdin/stdout (standard MCP transport). +Any MCP-compatible AI can connect:

+ +

Claude Desktop

+ +
{
+  "mcpServers": {
+    "xtop": {
+      "command": "xtop",
+      "args": ["mcp"]
+    }
+  }
+}
+ +

Interactive testing

+ +
# Call system_summary
+echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_summary","arguments":{}}}' | xtop mcp
+
+# Search processes with regex
+echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"processes_search","arguments":{"pattern":"/firefox|chrome/"}}}' | xtop mcp
+
+# Kill a process
+echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"process_kill","arguments":{"pid":1234}}}' | xtop mcp
+ +

Available MCP Tools

+ + + + + + + + + + + + + + + + +
ToolArgumentsDescription
system_summary(none)System health summary
processes_topcount, filter (regex)Top processes
processes_searchpattern, fieldsRegex search
process_infopidProcess details
process_killpidKill process
threshold_setcpu, mem, diskSet alert thresholds
threshold_get(none)Get thresholds
config_get(none)Get config
config_setinterval_ms / theme / layoutUpdate config
alerts_status(none)Active alerts
plugin_status(none)Plugin status
+ +
+ +

Process Analysis

+ +

Sentinel includes a heuristic analyzer that runs every 10 ticks (~10 seconds):

+ +
    +
  • Flags processes with CPU usage above 80%
  • +
  • Flags root-owned processes with CPU above 50%
  • +
  • Collects up to 20 alerts per cycle
  • +
  • Alerts are exposed via alerts.status command
  • +
+ +

The analysis is basic by design. Deeper security heuristics are delegated to domain experts.

+ +
+ +

AI Integration

+ +

Sentinel exposes two interfaces for AI agents:

+ +

Direct execute() (for embedded agents)

+

Callable from within xtop's plugin system via PluginManager::execute("sentinel", action, params).

+ +

MCP protocol (for external AI)

+

Run xtop mcp and configure your AI assistant's MCP client to connect. +The AI sees all Sentinel tools as callable functions with typed parameters.

+ +

Typical AI workflow via MCP:

+ +
    +
  1. AI calls system_summary to get a health overview
  2. +
  3. AI calls processes_top with optional regex filter
  4. +
  5. If suspicious, AI calls process_info for details
  6. +
  7. AI may call process_kill to terminate
  8. +
  9. AI calls alerts_status periodically for anomalies
  10. +
+ +
+ +

Future Plans

+ +
    +
  • MCP Resources: Push-based monitoring (server notifies AI on alerts)
  • +
  • Real-time streaming: Subscribe to metric changes without polling
  • +
  • Security analysis: Deeper heuristics for malicious process detection
  • +
  • Rich process metadata: Parent PID, executable hash, file descriptors, network sockets
  • +
+ +
+ +

+ Plugin System Docs · + Back to xtop +

diff --git a/plugins/xtop-plugin-sentinel/src/alert.rs b/plugins/xtop-plugin-sentinel/src/alert.rs new file mode 100644 index 0000000..4e2266d --- /dev/null +++ b/plugins/xtop-plugin-sentinel/src/alert.rs @@ -0,0 +1,45 @@ +use serde::Serialize; + +/// Severity level for a Sentinel alert. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Severity { + /// Low-priority, informative + Info, + /// Moderate concern, shown in widget + Warning, + /// High confidence threat + Critical, +} + +/// A single security alert produced by a heuristic rule. +#[derive(Debug, Clone, Serialize)] +pub struct SentinelAlert { + /// Short rule name (e.g. "suspicious_exe_path", "orphan_process") + pub rule: &'static str, + /// Severity level + pub severity: Severity, + /// Process ID that triggered the alert + pub pid: u32, + /// Process name + pub process_name: String, + /// Human-readable detail message + pub message: String, +} + +impl SentinelAlert { + pub fn new( + rule: &'static str, + severity: Severity, + pid: u32, + process_name: String, + message: String, + ) -> Self { + Self { + rule, + severity, + pid, + process_name, + message, + } + } +} diff --git a/plugins/xtop-plugin-sentinel/src/lib.rs b/plugins/xtop-plugin-sentinel/src/lib.rs new file mode 100644 index 0000000..35b88a7 --- /dev/null +++ b/plugins/xtop-plugin-sentinel/src/lib.rs @@ -0,0 +1,795 @@ +pub mod alert; +pub mod mcp; + +use std::collections::HashMap; +use std::fmt::Debug; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; +use regex::Regex; +use xtop_core::application::state::AppState; +use xtop_core::domain::metrics::ProcessInfo; +use xtop_core::domain::plugin::{ + Plugin, PluginCapability, PluginContext, PluginError, PluginManifest, WidgetRegistration, +}; + +use alert::{SentinelAlert, Severity}; + +// --------------------------------------------------------------------------- +// Known threat patterns (Rule 6) +// --------------------------------------------------------------------------- +const KNOWN_THREAT_NAMES: &[&str] = &[ + "minerd", "cpu_miner", "xmrig", "kdevtmpfsi", "kinsing", "diagree", + "watchbog", "sysguard", "crond64", "mkfile", "sysupdate", + "xmrig-nvidia", "xmrig-amd", "moneroocean", +]; + +const KNOWN_THREAT_CMDS: &[&str] = &[ + r"--donate-level", + r"--max-cpu-usage", + r"--threads", + r"pool\.monero", + r"pool\.supportxmr", + r"mine\.monero", +]; + +/// Path prefixes that are suspicious for executable locations. +const SUSPICIOUS_PATH_PREFIXES: &[&str] = &[ + "/tmp/", + "/dev/shm/", + "/var/tmp/", + "/proc/", + "/private/tmp/", + "/private/var/tmp/", +]; + +/// Processes allowed to be orphans (PPID=1). +const ALLOWED_ORPHANS: &[&str] = &[ + "systemd", "init", "launchd", "sshd", "login", "getty", "nginx", + "apache2", "httpd", "bash", "sh", "zsh", "tmux", "screen", +]; + +/// Browsers whose children are monitored (Rule 5). +const BROWSER_NAMES: &[&str] = &[ + "chrome", "firefox", "safari", "edge", "brave", "opera", "chromium", +]; + +/// Browser helper/sandbox processes that are allowed children. +const BROWSER_HELPERS: &[&str] = &[ + "helper", "plugin_container", "plugin_host", "gpu_process", + "renderer", "utility", "crashpad", "updater", +]; + +/// Pipe/download patterns for Rule 7. +const PIPE_PATTERNS: &[&str] = &[ + r"curl\s+.*\|\s*(ba|z)?sh", + r"wget\s+.*\|\s*(ba|z)?sh", + r"curl\s+.*\s*ba(?:sh)?\s*$", + r"python3?\s+-c\s+.*(?:import|urllib|requests|socket)", + r"base64\s+-d\s*\|", + r"eval\s*\$\(.*curl", + r"eval\s*\$\(.*wget", + r"bash\s+-c\s+.*\$\(curl", + r"bash\s+-c\s+.*\$\(wget", +]; + +/// High-thread-count processes that are allowed (Rule 8). +const ALLOWED_HIGH_THREAD: &[&str] = &[ + "chrome", "firefox", "code", "Code", "idea", "java", "dotnet", + "python", "node", "mysqld", "postgres", "Xorg", "dockerd", +]; + +/// Maximum alerts per cycle +const MAX_ALERTS: usize = 50; + +// --------------------------------------------------------------------------- +// The plugin struct +// --------------------------------------------------------------------------- + +pub struct SentinelPlugin { + enabled: bool, + tick_count: u64, + last_action: String, + last_action_result: String, + alerts: Vec, + // For spawn storm detection (Rule 10): name -> [(pid, start_time)] + spawn_history: HashMap>, +} + +impl SentinelPlugin { + pub fn new() -> Self { + Self { + enabled: true, + tick_count: 0, + last_action: "none".to_string(), + last_action_result: "ok".to_string(), + alerts: Vec::new(), + spawn_history: HashMap::new(), + } + } + + // ----------------------------------------------------------------------- + // Formatting helpers + // ----------------------------------------------------------------------- + + fn fmt_process(p: &ProcessInfo) -> serde_json::Value { + serde_json::json!({ + "pid": p.pid, + "name": p.name, + "cpu": (p.cpu_usage * 10.0).round() / 10.0, + "mem_bytes": p.memory, + "state": p.state, + "user": p.user_id.as_deref().unwrap_or("?"), + "cmd": p.cmd, + "exe": p.exe_path.as_deref().unwrap_or("?"), + "ppid": p.parent_pid, + "threads": p.thread_count, + "run_time": p.run_time, + "cwd": p.cwd.as_deref().unwrap_or("?"), + }) + } + + fn fmt_process_list(procs: &[ProcessInfo]) -> String { + let entries: Vec = procs.iter().map(Self::fmt_process).collect(); + serde_json::to_string(&entries).unwrap_or_default() + } + + fn fmt_alert(a: &SentinelAlert) -> serde_json::Value { + serde_json::json!({ + "rule": a.rule, + "severity": format!("{:?}", a.severity), + "pid": a.pid, + "process": a.process_name, + "message": a.message, + }) + } + + // ----------------------------------------------------------------------- + // System summary + // ----------------------------------------------------------------------- + + fn system_summary(&self, ctx: &PluginContext) -> String { + let snap = ctx.snapshot(); + let cpu_pct: f64 = snap.cpus.iter().map(|c| c.usage).sum::() + / snap.cpus.len().max(1) as f64; + let mem_gb = (snap.memory.used as f64 / 1073741824.0 * 10.0).round() / 10.0; + let mem_total_gb = (snap.memory.total as f64 / 1073741824.0 * 10.0).round() / 10.0; + let net_ifaces: Vec<&str> = snap.networks.iter().map(|n| n.name.as_str()).collect(); + + serde_json::to_string(&serde_json::json!({ + "cpu_avg": (cpu_pct * 10.0).round() / 10.0, + "mem_used_gb": mem_gb, + "mem_total_gb": mem_total_gb, + "mem_pct": snap.memory.percent.round() as u64, + "processes": snap.processes.len(), + "disks": snap.disks.len(), + "interfaces": net_ifaces, + "uptime_secs": snap.uptime, + "hostname": snap.sys_info.hostname, + "alerts": self.alerts.len(), + })).unwrap_or_default() + } + + // ----------------------------------------------------------------------- + // Search / listing + // ----------------------------------------------------------------------- + + fn search_processes(&self, ctx: &PluginContext, params: &str) -> Result { + let (pattern_str, fields) = if let Some(idx) = params.find(",fields=") { + let pat = ¶ms[..idx]; + let fields_part = ¶ms[idx + 8..]; + (pat, fields_part.split(',').collect::>()) + } else { + (params, vec!["name"]) + }; + + let pattern_str = pattern_str.trim(); + if pattern_str.is_empty() { + return Err(PluginError::Recoverable("search pattern cannot be empty".into())); + } + + let re = Regex::new(pattern_str).map_err(|e| { + PluginError::Recoverable(format!("invalid regex: {e}")) + })?; + + let snap = ctx.snapshot(); + let mut matched: Vec = snap + .processes + .into_iter() + .filter(|p| { + fields.iter().any(|f| match *f { + "name" => re.is_match(&p.name), + "cmd" => re.is_match(&p.cmd), + "user" => p.user_id.as_deref().map_or(false, |u| re.is_match(u)), + "state" => re.is_match(&p.state), + "exe" => p.exe_path.as_deref().map_or(false, |e| re.is_match(e)), + "cwd" => p.cwd.as_deref().map_or(false, |c| re.is_match(c)), + _ => false, + }) + }) + .collect(); + + matched.sort_by(|a, b| b.cpu_usage.partial_cmp(&a.cpu_usage).unwrap_or(std::cmp::Ordering::Equal)); + matched.truncate(100); + Ok(Self::fmt_process_list(&matched)) + } + + fn top_processes(&self, ctx: &PluginContext, params: &str) -> Result { + let (count_str, filter_pattern) = if let Some(idx) = params.find(",filter=") { + let cnt = ¶ms[..idx]; + let pat = ¶ms[idx + 8..]; + (cnt, Some(pat)) + } else { + (params, None) + }; + + let count = count_str.parse::().unwrap_or(10); + if count == 0 { + return Err(PluginError::Recoverable("count must be > 0".into())); + } + let snap = ctx.snapshot(); + let mut procs: Vec = snap.processes; + + if let Some(pattern) = filter_pattern { + let re = Regex::new(pattern).map_err(|e| { + PluginError::Recoverable(format!("invalid regex in filter: {e}")) + })?; + procs.retain(|p| re.is_match(&p.name) || re.is_match(&p.cmd) || p.exe_path.as_deref().map_or(false, |e| re.is_match(e))); + } + + procs.truncate(count); + Ok(Self::fmt_process_list(&procs)) + } + + fn process_info(&self, ctx: &PluginContext, pid_str: &str) -> Result { + let pid = pid_str.parse::().map_err(|_| { + PluginError::Recoverable(format!("invalid pid: {pid_str}")) + })?; + let snap = ctx.snapshot(); + let proc = snap.processes.iter().find(|p| p.pid == pid).ok_or_else(|| { + PluginError::Recoverable(format!("process {pid} not found")) + })?; + Ok(serde_json::to_string(&Self::fmt_process(proc)).unwrap_or_default()) + } + + // ----------------------------------------------------------------------- + // Heuristic rules + // ----------------------------------------------------------------------- + + /// Rule 1: Executable running from a suspicious path. + fn rule_suspicious_exe_path(&self, proc: &ProcessInfo) -> Option { + let exe = proc.exe_path.as_deref()?; + if SUSPICIOUS_PATH_PREFIXES.iter().any(|p| exe.starts_with(p)) { + Some(SentinelAlert::new( + "suspicious_exe_path", + Severity::Critical, + proc.pid, + proc.name.clone(), + format!("executable runs from suspicious path: {exe}"), + )) + } else { + None + } + } + + /// Rule 2: Orphan process (PPID=1) that is not a known system daemon. + fn rule_orphan_process(&self, proc: &ProcessInfo) -> Option { + if proc.parent_pid != Some(1) { + return None; + } + let name_lower = proc.name.to_lowercase(); + if ALLOWED_ORPHANS.iter().any(|a| name_lower.contains(a)) { + return None; + } + let severity = if proc.run_time < 60 { Severity::Critical } else { Severity::Warning }; + Some(SentinelAlert::new( + "orphan_process", + severity, + proc.pid, + proc.name.clone(), + format!( + "orphan process (PPID=1), running for {}s, exe: {}", + proc.run_time, + proc.exe_path.as_deref().unwrap_or("?"), + ), + )) + } + + /// Rule 3: Process masquerading (name != exe file stem OR system name not at canonical path). + fn rule_masquerading(&self, proc: &ProcessInfo) -> Option { + let exe = match proc.exe_path.as_deref() { + Some(e) => e, + None => return None, + }; + // Extract file stem from exe + let stem = std::path::Path::new(exe) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let name_lower = proc.name.to_lowercase(); + let stem_lower = stem.to_lowercase(); + + // Check if name is a known system process but exe is not at a canonical path + let known_system_names = ["svchost", "lsass", "launchd", "sshd", "systemd", "init"]; + if known_system_names.contains(&name_lower.as_str()) { + let canonical = exe.starts_with("/usr/") || exe.starts_with("/bin/") + || exe.starts_with("/sbin/") || exe.starts_with("/System/"); + if !canonical { + return Some(SentinelAlert::new( + "process_masquerading", + Severity::Critical, + proc.pid, + proc.name.clone(), + format!("process name '{}' masquerades as system process; exe: {exe}", proc.name), + )); + } + } + + // Check if name differs significantly from exe file stem + if name_lower != stem_lower && !stem_lower.is_empty() { + // Allow common cases like "python3" -> exe "/usr/bin/python3.11" + if !exe.contains(&name_lower) && !name_lower.contains(&stem_lower) { + return Some(SentinelAlert::new( + "process_masquerading", + Severity::Warning, + proc.pid, + proc.name.clone(), + format!("name '{}' differs from exe stem '{stem}' ({exe})", proc.name), + )); + } + } + + None + } + + /// Rule 4: Privilege escalation (EUID != UID). + fn rule_privilege_escalation(&self, proc: &ProcessInfo) -> Option { + let euid = match proc.effective_user_id.as_deref() { + Some(u) => u, + None => return None, + }; + let uid = match proc.user_id.as_deref() { + Some(u) => u, + None => return None, + }; + if euid == uid { + return None; + } + // Known SUID binaries that are allowed + let known_suid = ["/usr/bin/sudo", "/usr/bin/passwd", "/bin/ping", + "/usr/bin/ping", "/bin/su", "/usr/bin/su", "/usr/bin/newgrp", + "/usr/bin/gpasswd", "/usr/bin/chsh", "/usr/bin/chfn", + "/usr/bin/mount", "/usr/bin/umount"]; + let exe = proc.exe_path.as_deref().unwrap_or(""); + if known_suid.contains(&exe) { + return None; + } + let severity = if euid == "0" { Severity::Critical } else { Severity::Warning }; + Some(SentinelAlert::new( + "privilege_escalation", + severity, + proc.pid, + proc.name.clone(), + format!("EUID ({euid}) != UID ({uid}), exe: {exe}"), + )) + } + + /// Rule 5: Suspicious child of a browser process. + fn rule_suspicious_child_of_browser(&self, proc: &ProcessInfo, parent_map: &HashMap) -> Option { + let ppid = match proc.parent_pid { + Some(pid) => pid, + None => return None, + }; + let parent = match parent_map.get(&ppid) { + Some(p) => p, + None => return None, + }; + let parent_lower = parent.name.to_lowercase(); + let is_browser = BROWSER_NAMES.iter().any(|b| parent_lower.contains(b)); + if !is_browser { + return None; + } + let child_lower = proc.name.to_lowercase(); + let is_helper = BROWSER_HELPERS.iter().any(|h| child_lower.contains(h)); + if is_helper { + return None; + } + Some(SentinelAlert::new( + "suspicious_child_of_browser", + Severity::Warning, + proc.pid, + proc.name.clone(), + format!("browser '{}' spawned unknown child '{}'", parent.name, proc.name), + )) + } + + /// Rule 6: Known threat pattern (name or cmd matches miner/rootkit names). + fn rule_known_threat_pattern(&self, proc: &ProcessInfo) -> Option { + let name_lower = proc.name.to_lowercase(); + if KNOWN_THREAT_NAMES.iter().any(|t| name_lower.contains(t)) { + return Some(SentinelAlert::new( + "known_threat_pattern", + Severity::Critical, + proc.pid, + proc.name.clone(), + format!("process name matches known threat pattern: {}", proc.name), + )); + } + let cmd_joined = proc.cmd_full.join(" ").to_lowercase(); + if KNOWN_THREAT_CMDS.iter().any(|t| { + Regex::new(t).ok().map_or(false, |re| re.is_match(&cmd_joined)) + }) { + return Some(SentinelAlert::new( + "known_threat_pattern", + Severity::Critical, + proc.pid, + proc.name.clone(), + format!("command line matches known threat pattern: {}", proc.cmd), + )); + } + None + } + + /// Rule 7: Suspicious pipe/download pattern in command line. + fn rule_suspicious_pipe_or_download(&self, proc: &ProcessInfo) -> Option { + let cmd_joined = proc.cmd_full.join(" "); + for pattern in PIPE_PATTERNS { + if let Ok(re) = Regex::new(pattern) { + if re.is_match(&cmd_joined) { + return Some(SentinelAlert::new( + "suspicious_pipe_or_download", + Severity::Critical, + proc.pid, + proc.name.clone(), + format!("command matches pipe/download pattern: {}", proc.cmd), + )); + } + } + } + None + } + + /// Rule 8: High thread count anomaly. + fn rule_high_thread_anomaly(&self, proc: &ProcessInfo) -> Option { + if proc.thread_count < 500 { + return None; + } + let name_lower = proc.name.to_lowercase(); + if ALLOWED_HIGH_THREAD.iter().any(|a| name_lower.contains(a)) { + return None; + } + let severity = if proc.thread_count > 1000 || proc.cpu_usage > 200.0 { + Severity::Critical + } else { + Severity::Warning + }; + Some(SentinelAlert::new( + "high_thread_anomaly", + severity, + proc.pid, + proc.name.clone(), + format!("{} threads (CPU: {:.1}%)", proc.thread_count, proc.cpu_usage), + )) + } + + /// Rule 9: Suspicious file descriptor anomaly. + fn rule_suspicious_fd_anomaly(&self, proc: &ProcessInfo) -> Option { + if proc.open_files < 1000 { + return None; + } + let name_lower = proc.name.to_lowercase(); + let allowed = ["mysql", "postgres", "nginx", "httpd", "apache", + "chrome", "firefox", "code", "java", "dotnet", "dockerd"]; + if allowed.iter().any(|a| name_lower.contains(a)) { + return None; + } + Some(SentinelAlert::new( + "suspicious_fd_anomaly", + Severity::Info, + proc.pid, + proc.name.clone(), + format!("{} open file descriptors", proc.open_files), + )) + } + + /// Rule 10: Spawn storm detection. + fn rule_spawn_storm( + &mut self, + proc: &ProcessInfo, + now_run_time: u64, + ) -> Option { + if proc.run_time > 120 { + return None; + } + let entry = self + .spawn_history + .entry(proc.name.clone()) + .or_default(); + entry.push((proc.pid, proc.start_time)); + // Purge entries older than 120s + entry.retain(|(_, start)| now_run_time.saturating_sub(*start) < 120); + if entry.len() > 5 { + Some(SentinelAlert::new( + "recent_spawn_storm", + Severity::Warning, + proc.pid, + proc.name.clone(), + format!("{} new instances in last 120s", entry.len()), + )) + } else { + None + } + } + + // ----------------------------------------------------------------------- + // Main analyzer + // ----------------------------------------------------------------------- + + fn analyze_processes(&mut self, ctx: &PluginContext) -> Vec { + let snap = ctx.snapshot(); + let mut alerts: Vec = Vec::new(); + + // Build parent PID map for Rule 5 + let parent_map: HashMap = snap + .processes + .iter() + .map(|p| (p.pid, p)) + .collect(); + + let now_run_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + for proc in &snap.processes { + // Run rules in priority order + if let Some(a) = self.rule_suspicious_exe_path(proc) { + alerts.push(a); + } + if let Some(a) = self.rule_masquerading(proc) { + alerts.push(a); + } + if let Some(a) = self.rule_known_threat_pattern(proc) { + alerts.push(a); + } + if let Some(a) = self.rule_suspicious_pipe_or_download(proc) { + alerts.push(a); + } + if let Some(a) = self.rule_orphan_process(proc) { + alerts.push(a); + } + if let Some(a) = self.rule_privilege_escalation(proc) { + alerts.push(a); + } + if let Some(a) = self.rule_suspicious_child_of_browser(proc, &parent_map) { + alerts.push(a); + } + if let Some(a) = self.rule_high_thread_anomaly(proc) { + alerts.push(a); + } + if let Some(a) = self.rule_suspicious_fd_anomaly(proc) { + alerts.push(a); + } + if let Some(a) = self.rule_spawn_storm(proc, now_run_time) { + alerts.push(a); + } + } + + alerts.truncate(MAX_ALERTS); + alerts + } + + fn parse_thresholds(params: &str) -> Result<(f64, f64, f64), PluginError> { + let parts: Vec<&str> = params.split(',').collect(); + if parts.len() != 3 { + return Err(PluginError::Recoverable( + "expected cpu,mem,disk (3 comma-separated values)".into(), + )); + } + let cpu = parts[0].parse::().map_err(|e| { + PluginError::Recoverable(format!("invalid cpu threshold: {e}")) + })?; + let mem = parts[1].parse::().map_err(|e| { + PluginError::Recoverable(format!("invalid mem threshold: {e}")) + })?; + let disk = parts[2].parse::().map_err(|e| { + PluginError::Recoverable(format!("invalid disk threshold: {e}")) + })?; + Ok((cpu, mem, disk)) + } +} + +impl Debug for SentinelPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SentinelPlugin") + .field("enabled", &self.enabled) + .field("tick_count", &self.tick_count) + .field("alerts", &self.alerts.len()) + .finish() + } +} + +impl Plugin for SentinelPlugin { + fn manifest(&self) -> PluginManifest { + PluginManifest { + id: "sentinel".to_string(), + name: "Sentinel".to_string(), + version: "0.1.0".to_string(), + description: "AI-aware system monitoring, management, and heuristic threat detection".to_string(), + capabilities: vec![ + PluginCapability::ReadSystemInfo, + PluginCapability::KillProcesses, + PluginCapability::ModifyConfig, + PluginCapability::RenderWidgets, + ], + } + } + + fn on_enable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { + self.enabled = true; + Ok(()) + } + + fn on_disable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { + self.enabled = false; + Ok(()) + } + + fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError> { + self.tick_count += 1; + if self.tick_count % 5 == 0 { + self.alerts = self.analyze_processes(ctx); + } + Ok(()) + } + + fn widget(&self) -> Option { + Some(WidgetRegistration { + name: "sentinel".to_string(), + render: std::sync::Arc::new(|f: &mut ratatui::Frame, _state: &AppState, area: Rect| { + use xtop_core::domain::theme::hex_to_rgb_pub; + let bg = hex_to_rgb_pub("#1a1b2e"); + let fg = hex_to_rgb_pub("#7ec8e3"); + let accent = hex_to_rgb_pub("#c084fc"); + + let block = Block::default() + .title(" Sentinel ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2]))) + .style(Style::default().bg(Color::Rgb(bg[0], bg[1], bg[2])).fg(Color::Rgb(fg[0], fg[1], fg[2]))); + let inner = block.inner(area); + f.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .split(inner); + + let status = Paragraph::new("Agent: monitoring for threats -- use MCP to interact") + .style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2]))); + f.render_widget(status, chunks[0]); + + let info = Paragraph::new("run xtop mcp for AI tool integration") + .style(Style::default().fg(Color::Rgb(fg[0], fg[1], fg[2]))); + f.render_widget(info, chunks[1]); + }), + }) + } + + fn execute( + &mut self, + ctx: &mut PluginContext, + action: &str, + params: &str, + ) -> Result { + self.last_action = format!("{}({})", action, params); + + let result = match action { + "system.summary" => Ok(self.system_summary(ctx)), + "processes.top" => self.top_processes(ctx, params), + "processes.search" => self.search_processes(ctx, params), + "process.info" => self.process_info(ctx, params), + "process.kill" => { + let pid = params.parse::().map_err(|_| { + PluginError::Recoverable(format!("invalid pid: {params}")) + })?; + let ok = ctx.kill_process(pid) + .map_err(|e| PluginError::Recoverable(e.to_string()))?; + Ok(serde_json::to_string(&serde_json::json!({ + "killed": ok, + "pid": pid, + })).unwrap_or_default()) + } + "process.alerts" => { + let alerts_json: Vec = self.alerts.iter().map(Self::fmt_alert).collect(); + Ok(serde_json::to_string(&alerts_json).unwrap_or_default()) + } + "threshold.set" => { + let (cpu, mem, disk) = Self::parse_thresholds(params)?; + ctx.set_alert_thresholds(cpu, mem, disk) + .map_err(|e| PluginError::Recoverable(e.to_string()))?; + Ok(serde_json::to_string(&serde_json::json!({ + "cpu": cpu, "mem": mem, "disk": disk, "set": true, + })).unwrap_or_default()) + } + "threshold.get" => { + let alerts = ctx.state().alerts; + Ok(serde_json::to_string(&serde_json::json!({ + "cpu": alerts.cpu_high, + "mem": alerts.mem_high, + "disk": alerts.disk_high, + })).unwrap_or_default()) + } + "config.get" => { + let s = ctx.state(); + Ok(serde_json::to_string(&serde_json::json!({ + "theme": s.current_theme.name, + "layout": s.current_layout_name(), + "interval_ms": s.update_interval_ms, + "hostname": s.sys_info.hostname, + })).unwrap_or_default()) + } + "config.set" => { + if let Some(val) = params.strip_prefix("interval_ms=") { + let ms = val.parse::().map_err(|e| { + PluginError::Recoverable(format!("invalid interval_ms: {e}")) + })?; + ctx.set_update_interval(ms) + .map_err(|e| PluginError::Recoverable(e.to_string()))?; + Ok(serde_json::to_string(&serde_json::json!({ + "interval_ms": ms, "set": true, + })).unwrap_or_default()) + } else if let Some(name) = params.strip_prefix("theme=") { + let ok = ctx.set_theme_by_name(name) + .map_err(|e| PluginError::Recoverable(e.to_string()))?; + Ok(serde_json::to_string(&serde_json::json!({ + "theme": name, "set": ok, + })).unwrap_or_default()) + } else if let Some(name) = params.strip_prefix("layout=") { + let ok = ctx.set_layout_by_name(name) + .map_err(|e| PluginError::Recoverable(e.to_string()))?; + Ok(serde_json::to_string(&serde_json::json!({ + "layout": name, "set": ok, + })).unwrap_or_default()) + } else { + Err(PluginError::Recoverable( + "expected interval_ms=, theme=, or layout=".into(), + )) + } + } + "alerts.status" => { + let critical = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Critical)).count(); + let warning = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Warning)).count(); + let info_count = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Info)).count(); + let top: Vec = self.alerts.iter().take(5).map(Self::fmt_alert).collect(); + Ok(serde_json::to_string(&serde_json::json!({ + "total": self.alerts.len(), + "critical": critical, + "warning": warning, + "info": info_count, + "alerts": top, + })).unwrap_or_default()) + } + "plugin.status" => { + let critical = self.alerts.iter().filter(|a| matches!(a.severity, Severity::Critical)).count(); + Ok(serde_json::to_string(&serde_json::json!({ + "enabled": self.enabled, + "ticks": self.tick_count, + "last_action": self.last_action, + "last_result": self.last_action_result, + "active_alerts": self.alerts.len(), + "critical_alerts": critical, + })).unwrap_or_default()) + } + _ => { + return Err(PluginError::UnknownAction(action.to_string())); + } + }; + + match &result { + Ok(r) => self.last_action_result = format!("ok ({} chars)", r.len()), + Err(e) => self.last_action_result = format!("error: {e}"), + } + + result + } +} diff --git a/plugins/xtop-plugin-sentinel/src/mcp.rs b/plugins/xtop-plugin-sentinel/src/mcp.rs new file mode 100644 index 0000000..ccc52db --- /dev/null +++ b/plugins/xtop-plugin-sentinel/src/mcp.rs @@ -0,0 +1,317 @@ +//! MCP (Model Context Protocol) server for the Sentinel plugin. +//! +//! Runs on stdio transport and exposes Sentinel's `execute()` commands as MCP tools. +//! Any MCP-compatible AI (Claude Desktop, Cline, etc.) can connect via: +//! +//! ```json +//! { +//! "mcpServers": { +//! "xtop": { +//! "command": "xtop", +//! "args": ["mcp"] +//! } +//! } +//! } +//! ``` +//! +//! Protocol: JSON-RPC 2.0 over stdin/stdout (one JSON object per line). + +use std::io::{self, BufRead, Write}; +use xtop_core::application::state::AppState; + +const SERVER_NAME: &str = "xtop-sentinel"; +const SERVER_VERSION: &str = "0.1.0"; +const PROTOCOL_VERSION: &str = "2024-11-05"; + +/// Run the MCP server loop. +/// +/// Reads JSON-RPC messages from stdin, processes them via the Sentinel plugin's +/// `execute()` interface, and writes responses to stdout. +/// +/// `state` must already have the Sentinel plugin registered in its PluginManager. +pub fn run_server(state: &mut AppState) -> anyhow::Result<()> { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut stdout_lock = stdout.lock(); + + for line in stdin.lock().lines() { + let line = line.map_err(|e| anyhow::anyhow!("stdin read error: {e}"))?; + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + let parsed: serde_json::Value = serde_json::from_str(&line) + .map_err(|e| anyhow::anyhow!("invalid JSON-RPC: {e}"))?; + + let id = parsed.get("id").cloned(); + let method = parsed + .get("method") + .and_then(|m| m.as_str()) + .unwrap_or(""); + + let params = parsed.get("params").cloned().unwrap_or(serde_json::Value::Null); + + let response = match method { + "initialize" => handle_initialize(id, ¶ms), + "tools/list" => handle_tools_list(id), + "tools/call" => handle_tools_call(id, ¶ms, state), + _ => make_error(id, -32601, format!("Method not found: {method}")), + }; + + let response_line = serde_json::to_string(&response)?; + writeln!(stdout_lock, "{response_line}")?; + stdout_lock.flush()?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// JSON-RPC helpers +// --------------------------------------------------------------------------- + +fn make_result(id: Option, result: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }) +} + +fn make_error(id: Option, code: i32, message: String) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message } + }) +} + +// --------------------------------------------------------------------------- +// MCP: initialize +// --------------------------------------------------------------------------- + +fn handle_initialize(id: Option, _params: &serde_json::Value) -> serde_json::Value { + make_result(id, serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { "tools": {} }, + "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION } + })) +} + +// --------------------------------------------------------------------------- +// MCP: tools/list +// --------------------------------------------------------------------------- + +fn handle_tools_list(id: Option) -> serde_json::Value { + make_result(id, serde_json::json!({ + "tools": [ + { + "name": "system_summary", + "description": "Get a high-level system health summary (CPU, memory, disks, network, uptime, hostname)", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "processes_top", + "description": "Get top N processes by CPU usage, with optional regex filter", + "inputSchema": { + "type": "object", + "properties": { + "count": { "type": "integer", "description": "Number of processes (default 10)", "default": 10 }, + "filter": { "type": "string", "description": "Optional regex to filter by name or command" } + } + } + }, + { + "name": "processes_search", + "description": "Search processes using regex. Fields: name, cmd, user, state, exe, cwd", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { "type": "string", "description": "Regex pattern" }, + "fields": { "type": "string", "description": "Fields to search: name,cmd,user,state,exe,cwd (default: name)" } + }, + "required": ["pattern"] + } + }, + { + "name": "process_info", + "description": "Get detailed info about a process by PID (includes exe, ppid, threads, cwd)", + "inputSchema": { + "type": "object", + "properties": { + "pid": { "type": "integer", "description": "Process ID" } + }, + "required": ["pid"] + } + }, + { + "name": "process_kill", + "description": "Terminate a process by PID", + "inputSchema": { + "type": "object", + "properties": { + "pid": { "type": "integer", "description": "Process ID to kill" } + }, + "required": ["pid"] + } + }, + { + "name": "threshold_set", + "description": "Set alert thresholds for CPU, memory, and disk (percentages)", + "inputSchema": { + "type": "object", + "properties": { + "cpu": { "type": "number", "description": "CPU threshold" }, + "mem": { "type": "number", "description": "Memory threshold" }, + "disk": { "type": "number", "description": "Disk threshold" } + }, + "required": ["cpu", "mem", "disk"] + } + }, + { + "name": "threshold_get", + "description": "Get current alert threshold values", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "config_get", + "description": "Get current xtop configuration", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "config_set", + "description": "Update configuration: interval_ms, theme, or layout", + "inputSchema": { + "type": "object", + "properties": { + "interval_ms": { "type": "integer", "description": "Update interval in milliseconds" }, + "theme": { "type": "string", "description": "Theme name" }, + "layout": { "type": "string", "description": "Layout name" } + } + } + }, + { + "name": "process_alerts", + "description": "Get all heuristic alerts as a JSON array (suspicious_exe_path, masquerading, known_threat, pipe_download, orphan, privilege_escalation, browser_child, thread_anomaly, fd_anomaly, spawn_storm)", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "alerts_status", + "description": "Get alert summary with counts by severity (critical, warning, info)", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "plugin_status", + "description": "Get Sentinel plugin internal status", + "inputSchema": { "type": "object", "properties": {} } + } + ] + })) +} + +// --------------------------------------------------------------------------- +// MCP: tools/call +// --------------------------------------------------------------------------- + +fn handle_tools_call( + id: Option, + params: &serde_json::Value, + state: &mut AppState, +) -> serde_json::Value { + let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(serde_json::Value::Null); + + // Map MCP tool name -> Sentinel action + params string + let (action, params_str): (&str, String) = match name { + "system_summary" => ("system.summary", String::new()), + + "processes_top" => { + let count = args.get("count").and_then(|c| c.as_i64()).unwrap_or(10); + let filter = args.get("filter").and_then(|f| f.as_str()); + let p = match filter { + Some(f) => format!("{count},filter={f}"), + None => count.to_string(), + }; + ("processes.top", p) + } + + "processes_search" => { + let pattern = args.get("pattern").and_then(|p| p.as_str()).unwrap_or(""); + let fields = args.get("fields").and_then(|f| f.as_str()); + let p = match fields { + Some(f) => format!("{pattern},fields={f}"), + None => pattern.to_string(), + }; + ("processes.search", p) + } + + "process_info" => { + let pid = match args.get("pid").and_then(|p| p.as_i64()) { + Some(p) => p.to_string(), + None => return make_error(id, -32602, "missing required argument: pid".into()), + }; + ("process.info", pid) + } + + "process_kill" => { + let pid = match args.get("pid").and_then(|p| p.as_i64()) { + Some(p) => p.to_string(), + None => return make_error(id, -32602, "missing required argument: pid".into()), + }; + ("process.kill", pid) + } + + "threshold_set" => { + let cpu = match args.get("cpu").and_then(|c| c.as_f64()) { + Some(v) => v.to_string(), + None => return make_error(id, -32602, "missing required argument: cpu".into()), + }; + let mem = match args.get("mem").and_then(|m| m.as_f64()) { + Some(v) => v.to_string(), + None => return make_error(id, -32602, "missing required argument: mem".into()), + }; + let disk = match args.get("disk").and_then(|d| d.as_f64()) { + Some(v) => v.to_string(), + None => return make_error(id, -32602, "missing required argument: disk".into()), + }; + ("threshold.set", format!("{cpu},{mem},{disk}")) + } + + "threshold_get" => ("threshold.get", String::new()), + "config_get" => ("config.get", String::new()), + + "config_set" => { + if let Some(ms) = args.get("interval_ms").and_then(|v| v.as_i64()) { + ("config.set", format!("interval_ms={ms}")) + } else if let Some(theme) = args.get("theme").and_then(|v| v.as_str()) { + ("config.set", format!("theme={theme}")) + } else if let Some(layout) = args.get("layout").and_then(|v| v.as_str()) { + ("config.set", format!("layout={layout}")) + } else { + return make_error(id, -32602, "expected interval_ms, theme, or layout".into()); + } + } + + "process_alerts" => ("process.alerts", String::new()), + "alerts_status" => ("alerts.status", String::new()), + "plugin_status" => ("plugin.status", String::new()), + + _ => return make_error(id, -32601, format!("Tool not found: {name}")), + }; + + // Tick to refresh data (also ticks plugins) + state.on_tick(); + + // Execute via Sentinel plugin + let result_str = state.with_plugin_manager_mut(|mgr, this| { + mgr.execute(this, "sentinel", action, ¶ms_str) + }); + + match result_str { + Ok(json_str) => make_result(id, serde_json::json!({ + "content": [{"type": "text", "text": json_str}] + })), + Err(e) => make_error(id, -32000, e.to_string()), + } +} diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 3477216..0000000 --- a/src/app.rs +++ /dev/null @@ -1,183 +0,0 @@ -use crate::theme::{Theme, get_themes}; -use sysinfo::{System, RefreshKind, CpuRefreshKind, MemoryRefreshKind, Disks, Networks, ProcessRefreshKind, Components}; -use std::collections::HashMap; -use ratatui::widgets::TableState; - -#[allow(dead_code)] -pub enum InputMode { - Normal, - Editing, -} - -#[derive(Clone, Copy, PartialEq)] -pub enum LayoutMode { - Dashboard, // The current default split view - Vertical, // Everything stacked vertically - ProcessFocus, // Focus mainly on processes -} - -impl LayoutMode { - pub fn next(&self) -> Self { - match self { - LayoutMode::Dashboard => LayoutMode::Vertical, - LayoutMode::Vertical => LayoutMode::ProcessFocus, - LayoutMode::ProcessFocus => LayoutMode::Dashboard, - } - } -} - -pub struct App { - pub sys: System, - pub disks: Disks, - pub networks: Networks, - pub components: Components, - pub themes: HashMap, - pub current_theme: Theme, - pub should_quit: bool, - pub theme_list: Vec, - pub selected_theme_index: usize, - #[allow(dead_code)] - pub show_help: bool, - pub layout_mode: LayoutMode, - - // History Data for Charts - pub cpu_history: Vec>, - pub mem_history: Vec<(f64, f64)>, - #[allow(dead_code)] - pub swap_history: Vec<(f64, f64)>, - pub net_rx_history: Vec<(f64, f64)>, - pub net_tx_history: Vec<(f64, f64)>, - pub tick_count: f64, - - // UI States - #[allow(dead_code)] - pub process_table_state: TableState, -} - -impl App { - pub fn new() -> App { - let themes = get_themes(); - let mut theme_list: Vec = themes.keys().cloned().collect(); - theme_list.sort(); - - let default_theme_name = "x"; - let current_theme = themes.get(default_theme_name).cloned().unwrap_or_else(|| { - themes.values().next().unwrap().clone() - }); - - let selected_theme_index = theme_list.iter().position(|r| r == ¤t_theme.name).unwrap_or(0); - - let sys = System::new_with_specifics( - RefreshKind::nothing() - .with_cpu(CpuRefreshKind::everything()) - .with_memory(MemoryRefreshKind::everything()) - .with_processes(ProcessRefreshKind::everything()) - ); - let disks = Disks::new_with_refreshed_list(); - let networks = Networks::new_with_refreshed_list(); - let components = Components::new_with_refreshed_list(); - - let mut process_table_state = TableState::default(); - process_table_state.select(Some(0)); - - App { - sys, - disks, - networks, - components, - themes, - current_theme, - should_quit: false, - theme_list, - selected_theme_index, - show_help: false, - layout_mode: LayoutMode::Dashboard, - cpu_history: vec![], - mem_history: vec![], - swap_history: vec![], - net_rx_history: vec![], - net_tx_history: vec![], - tick_count: 0.0, - process_table_state, - } - } - - pub fn on_tick(&mut self) { - self.sys.refresh_all(); - self.disks.refresh(true); - self.networks.refresh(true); - self.components.refresh(true); - - self.tick_count += 1.0; - let x = self.tick_count; - - // Update CPU History - let cpus = self.sys.cpus(); - if self.cpu_history.len() != cpus.len() { - self.cpu_history = vec![vec![]; cpus.len()]; - } - for (i, cpu) in cpus.iter().enumerate() { - self.cpu_history[i].push((x, cpu.cpu_usage() as f64)); - if self.cpu_history[i].len() > 100 { - self.cpu_history[i].remove(0); - } - } - - // Update Memory History - let total_mem = self.sys.total_memory() as f64; - let used_mem = self.sys.used_memory() as f64; - let mem_usage = if total_mem > 0.0 { used_mem / total_mem * 100.0 } else { 0.0 }; - self.mem_history.push((x, mem_usage)); - if self.mem_history.len() > 100 { - self.mem_history.remove(0); - } - - // Update Net History - let mut total_rx = 0; - let mut total_tx = 0; - for (_, network) in &self.networks { - total_rx += network.received(); - total_tx += network.transmitted(); - } - - self.net_rx_history.push((x, total_rx as f64)); - self.net_tx_history.push((x, total_tx as f64)); - if self.net_rx_history.len() > 100 { - self.net_rx_history.remove(0); - self.net_tx_history.remove(0); - } - } - - pub fn next_theme(&mut self) { - if self.selected_theme_index >= self.theme_list.len() - 1 { - self.selected_theme_index = 0; - } else { - self.selected_theme_index += 1; - } - self.apply_theme(); - } - - pub fn previous_theme(&mut self) { - if self.selected_theme_index == 0 { - self.selected_theme_index = self.theme_list.len() - 1; - } else { - self.selected_theme_index -= 1; - } - self.apply_theme(); - } - - fn apply_theme(&mut self) { - let theme_name = &self.theme_list[self.selected_theme_index]; - if let Some(theme) = self.themes.get(theme_name) { - self.current_theme = theme.clone(); - } - } - - pub fn next_layout(&mut self) { - self.layout_mode = self.layout_mode.next(); - } - - pub fn quit(&mut self) { - self.should_quit = true; - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 08ffbaf..0000000 --- a/src/main.rs +++ /dev/null @@ -1,49 +0,0 @@ -mod app; -mod theme; -mod tui; -mod ui; - -use std::{error::Error, time::Duration}; -use crossterm::event::{self, Event, KeyCode}; -use app::App; - -#[tokio::main] -async fn main() -> Result<(), Box> { - tui::install_panic_hook(); - let mut terminal = tui::init()?; - let mut app = App::new(); - let tick_rate = Duration::from_millis(1000); // 1 second update - let mut last_tick = std::time::Instant::now(); - - loop { - terminal.draw(|f| ui::ui(f, &app))?; - - let timeout = tick_rate - .checked_sub(last_tick.elapsed()) - .unwrap_or_else(|| Duration::from_secs(0)); - - if crossterm::event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Char('q') => app.quit(), - KeyCode::Char('t') => app.next_theme(), - KeyCode::Char('T') => app.previous_theme(), - KeyCode::Char('l') => app.next_layout(), - _ => {} - } - } - } - - if last_tick.elapsed() >= tick_rate { - app.on_tick(); - last_tick = std::time::Instant::now(); - } - - if app.should_quit { - break; - } - } - - tui::restore()?; - Ok(()) -} diff --git a/src/theme.rs b/src/theme.rs deleted file mode 100644 index 119a65e..0000000 --- a/src/theme.rs +++ /dev/null @@ -1,112 +0,0 @@ -use ratatui::style::Color; -use std::collections::HashMap; - -#[derive(Clone, Debug)] -pub struct Theme { - pub name: String, - pub palette: [Color; 16], -} - -impl Theme { - pub fn bg(&self) -> Color { - self.palette[0] - } - - pub fn fg(&self) -> Color { - self.palette[7] - } - - #[allow(dead_code)] - pub fn graph_colors(&self) -> Vec { - vec![ - self.palette[1], // Red - self.palette[2], // Green - self.palette[3], // Yellow - self.palette[4], // Blue - self.palette[5], // Magenta - self.palette[6], // Cyan - ] - } -} - -fn hex_to_color(hex: &str) -> Color { - let hex = hex.trim_start_matches('#'); - let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); - let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); - let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); - Color::Rgb(r, g, b) -} - -pub fn get_themes() -> HashMap { - let mut themes = HashMap::new(); - - let definitions = vec![ - ("x", vec![ - "#363537", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff", - "#69676c", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff" - ]), - ("madrid", vec![ - "#333333", "#cc0033", "#009933", "#b8860b", "#0099cc", "#6633cc", "#0099cc", "#1a1a1a", - "#666666", "#cc0033", "#009933", "#b8860b", "#0099cc", "#6633cc", "#0099cc", "#1a1a1a" - ]), - ("lahabana", vec![ - "#363537", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff", - "#69676c", "#fc618d", "#7bd88f", "#e5ff9d", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff" - ]), - ("seul", vec![ - "#1b1b1b", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", "#f7f1ff", - "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", "#f7f1ff" - ]), - ("miami", vec![ - "#000000", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", "#f7f1ff", - "#69676c", "#FF4C8B", "#7FFFD4", "#FFD84C", "#00FFA8", "#D36CFF", "#47CFFF", "#f7f1ff" - ]), - ("paris", vec![ - "#222222", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", "#a3f3ff", "#f7f1ff", - "#525053", "#fc618d", "#7bd88f", "#fce566", "#a3f3ff", "#c4bdff", "#a3f3ff", "#f7f1ff" - ]), - ("tokio", vec![ - "#363537", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff", - "#69676c", "#fc618d", "#7bd88f", "#fce566", "#fd9353", "#948ae3", "#5ad4e6", "#f7f1ff" - ]), - ("oslo", vec![ - "#3f4451", "#e05561", "#8cc265", "#d18f52", "#4aa5f0", "#c162de", "#42b3c2", "#e6e6e6", - "#4f5666", "#ff616e", "#a5e075", "#f0a45d", "#4dc4ff", "#de73ff", "#4cd1e0", "#ffffff" - ]), - ("helsinki", vec![ - "#c0bbae", "#1faa9e", "#733d9a", "#2e70ad", "#b55a0f", "#3e9d21", "#bd4c3d", "#191919", - "#b0a999", "#009e91", "#5a1f8a", "#0f5ba2", "#b23b00", "#218c00", "#b32e1f", "#000000" - ]), - ("berlin", vec![ - "#000000", "#999999", "#bbbbbb", "#dddddd", "#888888", "#aaaaaa", "#cccccc", "#ffffff", - "#333333", "#bbbbbb", "#dddddd", "#ffffff", "#aaaaaa", "#cccccc", "#eeeeee", "#ffffff" - ]), - ("london", vec![ - "#000000", "#333333", "#444444", "#555555", "#666666", "#777777", "#888888", "#999999", - "#333333", "#444444", "#555555", "#666666", "#777777", "#888888", "#999999", "#aaaaaa" - ]), - ("praha", vec![ - "#1A1A1A", "#FF5555", "#B8E6A0", "#FFE4A3", "#BD93F9", "#FF9AA2", "#8BE9FD", "#FFFFFF", - "#6272A4", "#FF6E6E", "#B8E6A0", "#FFE4A3", "#D6ACFF", "#FF9AA2", "#A4FFFF", "#FFFFFF" - ]), - ("bogota", vec![ - "#222222", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", "#47e6ff", "#f7f1ff", - "#525053", "#fc618d", "#7bd88f", "#ffed89", "#47e6ff", "#ff9999", "#47e6ff", "#f7f1ff" - ]), - ]; - - for (name, colors) in definitions { - let mut palette = [Color::Reset; 16]; - for (i, hex) in colors.iter().enumerate() { - if i < 16 { - palette[i] = hex_to_color(hex); - } - } - themes.insert(name.to_string(), Theme { - name: name.to_string(), - palette, - }); - } - - themes -} diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index bbcad00..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,371 +0,0 @@ -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph, Gauge, Wrap, Table, Row, Cell, Chart, Dataset, Axis, GraphType}, - Frame, -}; -use crate::app::{App, LayoutMode}; -use sysinfo::{System, Process}; - -pub fn ui(f: &mut Frame, app: &App) { - match app.layout_mode { - LayoutMode::Dashboard => render_dashboard(f, app), - LayoutMode::Vertical => render_vertical(f, app), - LayoutMode::ProcessFocus => render_process_focus(f, app), - } -} - -fn render_dashboard(f: &mut Frame, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Percentage(45), // Top half (CPU, Mem, Net) - Constraint::Percentage(52), // Bottom half (Processes) - ]) - .split(f.area()); - - render_header(f, app, chunks[0]); - - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[1]); - - render_cpu(f, app, top_chunks[0]); - - let right_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(33), // Memory - Constraint::Percentage(33), // Storage - Constraint::Percentage(34), // Network - ]) - .split(top_chunks[1]); - - render_memory(f, app, right_chunks[0]); - render_storage(f, app, right_chunks[1]); - render_network(f, app, right_chunks[2]); - - render_processes(f, app, chunks[2]); -} - -fn render_vertical(f: &mut Frame, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Length(10), // CPU - Constraint::Length(10), // Memory - Constraint::Length(10), // Storage - Constraint::Length(6), // Network - Constraint::Min(0), // Processes - ]) - .split(f.area()); - - render_header(f, app, chunks[0]); - render_cpu(f, app, chunks[1]); - render_memory(f, app, chunks[2]); - render_storage(f, app, chunks[3]); - render_network(f, app, chunks[4]); - render_processes(f, app, chunks[5]); -} - -fn render_process_focus(f: &mut Frame, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header - Constraint::Length(10), // Quick Stats Row - Constraint::Min(0), // Processes (Dominant) - ]) - .split(f.area()); - - render_header(f, app, chunks[0]); - - let stats_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - ]) - .split(chunks[1]); - - render_cpu(f, app, stats_chunks[0]); - render_memory(f, app, stats_chunks[1]); - render_storage(f, app, stats_chunks[2]); - render_network(f, app, stats_chunks[3]); - - render_processes(f, app, chunks[2]); -} - -fn render_header(f: &mut Frame, app: &App, area: Rect) { - let load_avg = System::load_average(); - let uptime = System::uptime(); - let days = uptime / 86400; - let hours = (uptime % 86400) / 3600; - let minutes = (uptime % 3600) / 60; - let seconds = uptime % 60; - - let mode_str = match app.layout_mode { - LayoutMode::Dashboard => "Dashboard", - LayoutMode::Vertical => "Vertical", - LayoutMode::ProcessFocus => "Process Focus", - }; - - let text = format!( - "xtop | Theme: {} | Layout: {} | Uptime: {}d {}h {}m {}s | Load: {:.2} {:.2} {:.2} | [q] Quit [t] Theme [l] Layout", - app.current_theme.name, mode_str, days, hours, minutes, seconds, load_avg.one, load_avg.five, load_avg.fifteen - ); - - let p = Paragraph::new(text) - .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg())) - .block(Block::default().borders(Borders::ALL).title("System Info")); - f.render_widget(p, area); -} - -fn render_cpu(f: &mut Frame, app: &App, area: Rect) { - // Try to get max temp - let mut max_temp = 0.0; - for component in &app.components { - if component.label().to_lowercase().contains("core") || component.label().to_lowercase().contains("cpu") { - if let Some(temp) = component.temperature() { - if temp > max_temp { - max_temp = temp; - } - } - } - } - - let title = if max_temp > 0.0 { - format!("CPU (Max: {:.1}°C)", max_temp) - } else { - "CPU".to_string() - }; - - let block = Block::default().title(title).borders(Borders::ALL) - .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg())); - let inner_area = block.inner(area); - f.render_widget(block, area); - - let cpus = app.sys.cpus(); - let count = cpus.len(); - if count == 0 { return; } - - let constraints = if inner_area.width > 40 { - vec![Constraint::Percentage(50), Constraint::Percentage(50)] - } else { - vec![Constraint::Percentage(100)] - }; - - let cols = Layout::default().direction(Direction::Horizontal).constraints(constraints).split(inner_area); - - let items_per_col = (count + 1) / cols.len(); - - for (col_idx, col_area) in cols.iter().enumerate() { - let start = col_idx * items_per_col; - let end = (start + items_per_col).min(count); - - let rows = Layout::default().direction(Direction::Vertical) - .constraints(vec![Constraint::Length(1); end - start]) - .split(*col_area); - - for (i, row_area) in rows.iter().enumerate() { - let cpu_idx = start + i; - if cpu_idx >= count { break; } - - let cpu = &cpus[cpu_idx]; - let usage = cpu.cpu_usage(); - let label = format!("CPU{:<2} {:>3.0}%", cpu_idx, usage); - - let gauge = Gauge::default() - .gauge_style(Style::default().fg(app.current_theme.palette[1 + (cpu_idx % 6)]).bg(app.current_theme.bg())) - .percent(usage as u16) - .label(label); - f.render_widget(gauge, *row_area); - } - } -} - -fn render_memory(f: &mut Frame, app: &App, area: Rect) { - let block = Block::default().title("Memory").borders(Borders::ALL) - .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg())); - let inner_area = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default().direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(3), Constraint::Min(0)]) - .split(inner_area); - - // RAM Stats - let total_mem = app.sys.total_memory(); - let used_mem = app.sys.used_memory(); - let _free_mem = app.sys.free_memory(); - let available_mem = app.sys.available_memory(); - - let mem_pct = if total_mem > 0 { (used_mem as f64 / total_mem as f64 * 100.0) as u16 } else { 0 }; - - let mem_text = format!( - "RAM: Total: {:.1} GB | Used: {:.1} GB | Avail: {:.1} GB", - total_mem as f64 / 1024.0 / 1024.0 / 1024.0, - used_mem as f64 / 1024.0 / 1024.0 / 1024.0, - available_mem as f64 / 1024.0 / 1024.0 / 1024.0 - ); - - let mem_gauge = Gauge::default() - .gauge_style(Style::default().fg(app.current_theme.palette[2]).bg(app.current_theme.bg())) - .percent(mem_pct) - .label(mem_text); - f.render_widget(mem_gauge, chunks[0]); - - // Swap Stats - let total_swap = app.sys.total_swap(); - let used_swap = app.sys.used_swap(); - let free_swap = app.sys.free_swap(); - let swap_pct = if total_swap > 0 { (used_swap as f64 / total_swap as f64 * 100.0) as u16 } else { 0 }; - - let swap_text = format!( - "SWP: Total: {:.1} GB | Used: {:.1} GB | Free: {:.1} GB", - total_swap as f64 / 1024.0 / 1024.0 / 1024.0, - used_swap as f64 / 1024.0 / 1024.0 / 1024.0, - free_swap as f64 / 1024.0 / 1024.0 / 1024.0 - ); - - let swap_gauge = Gauge::default() - .gauge_style(Style::default().fg(app.current_theme.palette[3]).bg(app.current_theme.bg())) - .percent(swap_pct) - .label(swap_text); - f.render_widget(swap_gauge, chunks[1]); - - // History Chart - let datasets = vec![ - Dataset::default() - .name("RAM Usage") - .marker(symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(app.current_theme.palette[2])) - .data(&app.mem_history), - ]; - - let chart = Chart::new(datasets) - .block(Block::default().borders(Borders::TOP)) - .x_axis(Axis::default().bounds([app.tick_count - 100.0, app.tick_count])) - .y_axis(Axis::default().bounds([0.0, 100.0])); - f.render_widget(chart, chunks[2]); -} - -fn render_storage(f: &mut Frame, app: &App, area: Rect) { - let block = Block::default().title("Storage").borders(Borders::ALL) - .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg())); - let inner_area = block.inner(area); - f.render_widget(block, area); - - // List all disks - // We need to calculate how many lines per disk to fit them all, or just list them. - let disk_count = app.disks.len(); - if disk_count == 0 { return; } - - let constraints = vec![Constraint::Length(3); disk_count]; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(inner_area); - - for (i, disk) in app.disks.iter().enumerate() { - if i >= chunks.len() { break; } - - let total_space = disk.total_space(); - let available_space = disk.available_space(); - let used_space = total_space - available_space; - let disk_pct = if total_space > 0 { (used_space as f64 / total_space as f64 * 100.0) as u16 } else { 0 }; - - // "encima total uso y libre" -> We put stats in the label or above. - // Let's format it nicely: "/mnt/data Total: 100G Used: 50G Free: 50G" - let label = format!( - "{:?} Tot: {:.0}G Use: {:.0}G Free: {:.0}G", - disk.mount_point(), - total_space as f64 / 1024.0 / 1024.0 / 1024.0, - used_space as f64 / 1024.0 / 1024.0 / 1024.0, - available_space as f64 / 1024.0 / 1024.0 / 1024.0 - ); - - let gauge = Gauge::default() - .gauge_style(Style::default().fg(app.current_theme.palette[4]).bg(app.current_theme.bg())) - .percent(disk_pct) - .label(label); - f.render_widget(gauge, chunks[i]); - } -} - -fn render_network(f: &mut Frame, app: &App, area: Rect) { - let block = Block::default().title("Network").borders(Borders::ALL) - .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg())); - let inner_area = block.inner(area); - f.render_widget(block, area); - - let mut total_rx = 0; - let mut total_tx = 0; - for (_, network) in &app.networks { - total_rx += network.received(); - total_tx += network.transmitted(); - } - - let text = vec![ - Line::from(vec![ - Span::styled("Total RX: ", Style::default().fg(app.current_theme.fg())), - Span::styled(format!("{:.2} MB", total_rx as f64 / 1024.0 / 1024.0), Style::default().fg(app.current_theme.palette[4])), - ]), - Line::from(vec![ - Span::styled("Total TX: ", Style::default().fg(app.current_theme.fg())), - Span::styled(format!("{:.2} MB", total_tx as f64 / 1024.0 / 1024.0), Style::default().fg(app.current_theme.palette[5])), - ]), - ]; - - let p = Paragraph::new(text).wrap(Wrap { trim: true }); - f.render_widget(p, inner_area); -} - -fn render_processes(f: &mut Frame, app: &App, area: Rect) { - let block = Block::default().title("Processes").borders(Borders::ALL) - .style(Style::default().fg(app.current_theme.fg()).bg(app.current_theme.bg())); - let inner_area = block.inner(area); - f.render_widget(block, area); - - let mut procs: Vec<&Process> = app.sys.processes().values().collect(); - procs.sort_by(|a, b| b.cpu_usage().partial_cmp(&a.cpu_usage()).unwrap_or(std::cmp::Ordering::Equal)); - - let procs = procs.iter().take(50); - - // Faint line separator - let separator = Span::styled(" | ", Style::default().fg(app.current_theme.palette[8])); // Assuming palette[8] is faint/gray - - let rows: Vec = procs.map(|p| { - Row::new(vec![ - Cell::from(Line::from(vec![Span::raw(p.pid().to_string()), separator.clone()])), - Cell::from(Line::from(vec![Span::raw(p.name().to_string_lossy().into_owned()), separator.clone()])), - Cell::from(Line::from(vec![Span::raw(format!("{:.1}%", p.cpu_usage())), separator.clone()])), - Cell::from(Line::from(vec![Span::raw(format!("{:.1} MB", p.memory() as f64 / 1024.0 / 1024.0)), separator.clone()])), - Cell::from(p.user_id().map(|u| u.to_string()).unwrap_or_else(|| "?".to_string())), - ]) - .style(Style::default().fg(app.current_theme.fg())) - }).collect(); - - let widths = [ - Constraint::Length(10), // Increased slightly for separator - Constraint::Percentage(40), - Constraint::Length(12), - Constraint::Length(17), - Constraint::Length(10), - ]; - - let table = Table::new(rows, widths) - .header( - Row::new(vec!["PID |", "Name |", "CPU% |", "Mem |", "User"]) - .style(Style::default().fg(app.current_theme.palette[6]).add_modifier(Modifier::BOLD)) - .bottom_margin(1) - ) - .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - f.render_widget(table, inner_area); -}